Skip to content

Commit b70ebaf

Browse files
anticomputerCopilot
andcommitted
refactor(runner): unify repeat_prompt and multi-model capture into one path
Every prompt task now fans out into branches (the cross product of prompts x models) that each capture into an isolated per-branch sink, regardless of which axis fanned out. After the fan-out the runner: - projects single-model branch results back into the shared store in branch order, preserving the implicit last-tool-result carry-over, run-level results, and session snapshot (now deterministic even for async repeat_prompt); multi-model tasks are excluded on purpose (no single "last" across models); - captures the named output uniformly: a plain task publishes its single validated value, and any fan-out task (repeat_prompt, multiple models, or the cross product) publishes the per-branch fan-in list [{model, item, result}]. This removes the multi_model fork in the capture layer and fixes two asymmetries: a single-model repeat_prompt can now fan in per-item results (not just the last), and an async single-model repeat no longer races the shared store. The capture model is documented in a design note above _aggregate_fanin and in doc/GRAMMAR.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4498388 commit b70ebaf

3 files changed

Lines changed: 204 additions & 64 deletions

File tree

doc/GRAMMAR.md

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -411,13 +411,23 @@ that later tasks consume by name.
411411

412412
Three fields drive this:
413413

414-
- `id` names a task. Its produced value (its final tool result) is exposed to
415-
later tasks as `outputs.<id>`.
414+
- `id` names a task, exposing its output to later tasks as `outputs.<id>`. The
415+
shape depends on whether the task fans out:
416+
- A plain task (single model, no `repeat_prompt`) publishes its single
417+
produced value (its final tool result).
418+
- A task that fans out (`repeat_prompt`, multiple `models`, or their cross
419+
product) publishes a per-branch fan-in list of
420+
`{"model": <label>, "item": <index>, "result": <value>}` records, one per
421+
branch. This is uniform across the item axis and the model axis, so a
422+
single-model `repeat_prompt` and a multi-model task capture the same way.
416423
- `outputs` declares an inline JSON Schema (Draft 2020-12). When present, the
417424
task's value is validated against it before being stored. Validation is strict
418425
and does not coerce, so a value whose types do not match the contract is a
419-
failure. A malformed schema is rejected when the taskflow is loaded, before
420-
any model calls are made.
426+
failure. On a fan-out task the schema is applied to each branch's `result`
427+
(a violation is a failed branch under the `completion` policy); on a plain
428+
task it is applied to the single value (a violation is a hard failure). A
429+
malformed schema is rejected when the taskflow is loaded, before any model
430+
calls are made.
421431
- `over` is an explicit iterable selector for `repeat_prompt`: a Jinja
422432
expression evaluated against the template context (so it yields a real list,
423433
not a re-parsed string).
@@ -473,11 +483,10 @@ Notes:
473483
`values`, ...) resolve to your data, not the method.
474484
- Without `id`/`outputs`/`over`, the implicit last-tool-result `repeat_prompt`
475485
behaviour is unchanged.
476-
- On multi-model tasks, `id` fans in per-branch results as a list of
477-
`{model, item, result}` records (see "Multiple Models"). When an `outputs`
478-
schema is present it is applied per branch: each `result` is the validated
479-
value, and a branch that violates the schema is treated as a failed branch
480-
under the task's `completion` policy.
486+
- Implicit carry-over (the next task's `repeat_prompt` reading the previous
487+
task's last tool result) is fed by single-model tasks only. A multi-model
488+
task does not feed it: there is no single "last" result across models, so a
489+
downstream task must consume its output by name via `id`/`over`.
481490

482491
### Toolboxes / MCP Servers
483492

src/seclab_taskflow_agent/runner.py

Lines changed: 116 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
246282
def _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)

tests/test_runner_integration.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,3 +444,73 @@ async def failing_deploy(available_tools, agents, prompt, *, model=None, record_
444444
data = _session_data(tmp_path)
445445
assert data["finished"] is False
446446
assert "did not complete" in data["error"]
447+
448+
449+
class TestUnifiedCapture:
450+
"""The unified capture model: repeat_prompt and multi-model both fan in,
451+
plain tasks stay scalar, and single-model results still feed carry-over."""
452+
453+
def test_single_model_repeat_fans_in_per_item(self, monkeypatch, tmp_path):
454+
async def deploy(available_tools, agents, prompt, *, model=None, record_tool_result=None, **kw):
455+
if record_tool_result is not None:
456+
await record_tool_result(ToolResult(text=json.dumps({"echo": prompt})))
457+
return True
458+
459+
task = TaskDefinition(
460+
id="items", agents=["pkg.p"], user_prompt="do {{ result }}",
461+
repeat_prompt=True, over="globals.xs",
462+
)
463+
_run(monkeypatch, tmp_path, _taskflow(task, globals_={"xs": ["a", "b", "c"]}), deploy_impl=deploy)
464+
records = _session_outputs(tmp_path)["items"]
465+
# A per-item fan-in list (previously only the last item was captured).
466+
assert [r["item"] for r in records] == [0, 1, 2]
467+
assert records[0]["result"] == {"echo": "do a"}
468+
assert records[2]["result"] == {"echo": "do c"}
469+
470+
def test_plain_single_task_output_is_scalar(self, monkeypatch, tmp_path):
471+
async def deploy(available_tools, agents, prompt, *, model=None, record_tool_result=None, **kw):
472+
if record_tool_result is not None:
473+
await record_tool_result(ToolResult(text=json.dumps({"answer": 42})))
474+
return True
475+
476+
task = TaskDefinition(id="v", agents=["pkg.p"], user_prompt="hi")
477+
_run(monkeypatch, tmp_path, _taskflow(task), deploy_impl=deploy)
478+
# A task that does not fan out publishes its single value, not a list.
479+
assert _session_outputs(tmp_path)["v"] == {"answer": 42}
480+
481+
def test_single_model_result_feeds_implicit_carryover(self, monkeypatch, tmp_path):
482+
calls: list[str] = []
483+
484+
async def deploy(available_tools, agents, prompt, *, model=None, record_tool_result=None, **kw):
485+
calls.append(prompt)
486+
if record_tool_result is not None:
487+
payload = ["x", "y"] if prompt.startswith("produce") else {"echo": prompt}
488+
await record_tool_result(ToolResult(text=json.dumps(payload)))
489+
return True
490+
491+
tasks = [
492+
TaskDefinition(agents=["pkg.p"], user_prompt="produce the list"),
493+
TaskDefinition(agents=["pkg.p"], user_prompt="handle {{ result }}", repeat_prompt=True),
494+
]
495+
_run(monkeypatch, tmp_path, _taskflow_tasks(tasks), deploy_impl=deploy)
496+
# Task B repeated once per element of Task A's carried-over result, which
497+
# only works if Task A's branch result was projected into the store.
498+
assert [c for c in calls if c.startswith("handle")] == ["handle x", "handle y"]
499+
500+
def test_async_repeat_fanin_is_ordered_despite_completion_order(self, monkeypatch, tmp_path):
501+
async def deploy(available_tools, agents, prompt, *, model=None, record_tool_result=None, **kw):
502+
# Earlier items sleep longer so they finish last; the fan-in must
503+
# still be in item (submission) order, not completion order.
504+
n = int(prompt.split()[-1])
505+
await asyncio.sleep((3 - n) * 0.01)
506+
if record_tool_result is not None:
507+
await record_tool_result(ToolResult(text=json.dumps({"n": n})))
508+
return True
509+
510+
task = TaskDefinition(
511+
id="items", agents=["pkg.p"], user_prompt="item {{ result }}",
512+
repeat_prompt=True, over="globals.xs", async_task=True, async_limit=3,
513+
)
514+
_run(monkeypatch, tmp_path, _taskflow(task, globals_={"xs": [0, 1, 2]}), deploy_impl=deploy)
515+
records = _session_outputs(tmp_path)["items"]
516+
assert [r["result"]["n"] for r in records] == [0, 1, 2]

0 commit comments

Comments
 (0)