Skip to content

Commit 4e46216

Browse files
anticomputerCopilot
andcommitted
fix(runner): capture a plain task's output from its own result, not the store
_capture_task_output read the run-global store.last(), so a non-fan-out task that produced no tool result silently captured the previous task's result as its own outputs.<id> (and could validate it against this task's schema). Pass the task's own last result explicitly -- its single branch's last tool result, or the shell result -- so an empty producer yields None (or a hard failure when a schema is declared) instead of leaking prior-task data. The fan-out path already read per-branch sinks and was unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4579480 commit 4e46216

3 files changed

Lines changed: 43 additions & 12 deletions

File tree

src/seclab_taskflow_agent/runner.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -408,16 +408,19 @@ def _capture_task_output(
408408
output_id: str,
409409
schema: dict[str, Any],
410410
task_name: str,
411+
last: ToolResult | None,
411412
) -> None:
412-
"""Capture a task's final tool result as a named typed output.
413+
"""Capture a non-fan-out task's own final tool result as a named output.
413414
414-
The task's output is its most recent tool result. When *schema* is
415-
non-empty the decoded value is validated against it (raising a
415+
*last* is this task's produced result (its branch's last tool result, or
416+
the shell result), **not** the run-global store's last entry -- so a task
417+
that produced nothing yields ``None`` (or a hard failure when a schema is
418+
declared) rather than silently leaking the previous task's result. When
419+
*schema* is non-empty the decoded value is validated against it (raising a
416420
clear error on mismatch); otherwise the decoded value is stored as-is,
417421
falling back to the raw text when it is not JSON. The result is exposed to
418422
later tasks as ``outputs.<output_id>``.
419423
"""
420-
last = store.last()
421424
if last is None:
422425
if schema:
423426
raise ValueError(
@@ -1329,9 +1332,19 @@ async def _branch_record(tr: ToolResult) -> None:
13291332
if fans_out:
13301333
store.set_output(task_output_id, _aggregate_fanin(captured_branches))
13311334
else:
1335+
# This task's own last result: the single branch's last
1336+
# tool result for a model task, or the shell result for a
1337+
# shell task (which has no branches and wrote directly to
1338+
# the store). Never the run-global store.last(), so an
1339+
# empty producer does not leak the previous task's result.
1340+
if captured_branches:
1341+
_sink = captured_branches[-1].sink
1342+
_own_last = _sink[-1] if _sink else None
1343+
else:
1344+
_own_last = store.last()
13321345
try:
13331346
_capture_task_output(
1334-
store, task_output_id, task_output_schema, task_name
1347+
store, task_output_id, task_output_schema, task_name, _own_last
13351348
)
13361349
except Exception as exc:
13371350
logging.error("Task %r output capture failed: %s", task_name, exc)

tests/test_runner.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -814,13 +814,13 @@ class TestCaptureTaskOutput:
814814
def test_capture_schemaless_decodes_json(self):
815815
store = ResultStore()
816816
store.record(ToolResult(text=json.dumps({"functions": [1, 2]})))
817-
_capture_task_output(store, "list_fns", {}, "task-0")
817+
_capture_task_output(store, "list_fns", {}, "task-0", store.last())
818818
assert store.outputs["list_fns"] == {"functions": [1, 2]}
819819

820820
def test_capture_schemaless_falls_back_to_text(self):
821821
store = ResultStore()
822822
store.record(ToolResult(text="a plain answer"))
823-
_capture_task_output(store, "answer", {}, "task-0")
823+
_capture_task_output(store, "answer", {}, "task-0", store.last())
824824
assert store.outputs["answer"] == "a plain answer"
825825

826826
def test_capture_with_schema_validates(self):
@@ -840,7 +840,7 @@ def test_capture_with_schema_validates(self):
840840
},
841841
"required": ["functions"],
842842
}
843-
_capture_task_output(store, "list_fns", schema, "task-0")
843+
_capture_task_output(store, "list_fns", schema, "task-0", store.last())
844844
assert store.outputs["list_fns"] == {"functions": [{"name": "f", "body": "b"}]}
845845

846846
def test_capture_with_schema_validation_error(self):
@@ -854,23 +854,23 @@ def test_capture_with_schema_validation_error(self):
854854
"required": ["functions"],
855855
}
856856
with pytest.raises(ValidationError):
857-
_capture_task_output(store, "list_fns", schema, "task-0")
857+
_capture_task_output(store, "list_fns", schema, "task-0", store.last())
858858

859859
def test_capture_no_result_with_schema_raises(self):
860860
store = ResultStore()
861861
schema = {"type": "object", "properties": {"f": {"type": "string"}}, "required": ["f"]}
862862
with pytest.raises(ValueError, match="produced no tool result"):
863-
_capture_task_output(store, "x", schema, "task-0")
863+
_capture_task_output(store, "x", schema, "task-0", None)
864864

865865
def test_capture_no_result_schemaless_sets_none(self):
866866
store = ResultStore()
867-
_capture_task_output(store, "x", {}, "task-0")
867+
_capture_task_output(store, "x", {}, "task-0", None)
868868
assert store.outputs["x"] is None
869869

870870
def test_capture_prefers_structured(self):
871871
store = ResultStore()
872872
store.record(ToolResult(structured={"functions": ["a"]}))
873-
_capture_task_output(store, "out", {}, "task-0")
873+
_capture_task_output(store, "out", {}, "task-0", store.last())
874874
assert store.outputs["out"] == {"functions": ["a"]}
875875

876876

tests/test_runner_integration.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,3 +531,21 @@ def test_shell_task_never_fans_out(self, monkeypatch, tmp_path):
531531
task = TaskDefinition(id="sh", run='echo \'{"x": 1}\'', models=["a", "b"])
532532
_run(monkeypatch, tmp_path, _taskflow(task))
533533
assert _session_outputs(tmp_path)["sh"] == {"x": 1}
534+
535+
def test_plain_task_with_no_result_does_not_leak_prior(self, monkeypatch, tmp_path):
536+
# Task A produces a result; Task B produces no tool result. B's output
537+
# must be None (its own empty result), never A's result via the
538+
# run-global store.
539+
async def deploy(available_tools, agents, prompt, *, model=None, record_tool_result=None, **kw):
540+
if record_tool_result is not None and prompt.startswith("produce"):
541+
await record_tool_result(ToolResult(text=json.dumps({"a": 1})))
542+
return True
543+
544+
tasks = [
545+
TaskDefinition(id="a", agents=["pkg.p"], user_prompt="produce a"),
546+
TaskDefinition(id="b", agents=["pkg.p"], user_prompt="answer without a tool"),
547+
]
548+
_run(monkeypatch, tmp_path, _taskflow_tasks(tasks), deploy_impl=deploy)
549+
outputs = _session_outputs(tmp_path)
550+
assert outputs["a"] == {"a": 1}
551+
assert outputs["b"] is None

0 commit comments

Comments
 (0)