Skip to content

Commit 4498388

Browse files
anticomputerCopilot
andcommitted
fix(runner): address review feedback on outputs docs, empty iterables, exit code
- Correct stale "validated/coerced" wording in comments/docstrings/grammar docs: output-schema validation is strict and does not coerce. - Convert the remaining old-DSL `outputs` snippet in the `if` docs to JSON Schema. - Fix the `_build_prompts_to_run` docstring: a non-iterable derived value raises TypeError (from iter/list), not ValueError. - Materialize a repeat_prompt `over:` iterable before the emptiness check so a one-shot generator (e.g. Jinja map/select) is detected as empty instead of silently producing zero prompts. - Raise instead of break on a must_complete task failure so run_main propagates and the CLI exits non-zero, matching the other hard-failure paths; the session is still saved for --resume. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 264dbde commit 4498388

5 files changed

Lines changed: 71 additions & 13 deletions

File tree

doc/GRAMMAR.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ Notes and semantics:
178178
branch's final result is aggregated into `outputs.<id>` as a list of records
179179
`{"model": ..., "item": ..., "result": ...}` (see "Typed named outputs").
180180
- 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
181+
branch's result is validated against the schema and stored as the
182182
`result` of its fan-in record. A branch whose result violates the schema is
183183
treated as a failed branch, which the `completion` policy (`all`/`any`) then
184184
reduces like any other branch failure (see "Typed named outputs").
@@ -212,7 +212,11 @@ as skipped and not run); otherwise it runs normally.
212212
user_prompt: |
213213
Audit this code and report findings as JSON: {"findings": [...]}
214214
outputs:
215-
findings: list[any]
215+
type: object
216+
properties:
217+
findings:
218+
type: array
219+
required: [findings]
216220
- task:
217221
# only remediate when the audit actually found something
218222
if: "outputs.audit.findings | length > 0"

src/seclab_taskflow_agent/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ class TaskDefinition(BaseModel):
122122
model: str = ""
123123
model_settings: dict[str, Any] = Field(default_factory=dict)
124124
# Typed named outputs (M2): declare a schema for this task's produced
125-
# value; when set, the captured output is validated/coerced and exposed to
125+
# value; when set, the captured output is validated and exposed to
126126
# later tasks as ``outputs.<id>``.
127127
outputs: dict[str, Any] = Field(default_factory=dict)
128128
# Explicit iterable selector for repeat_prompt: a Jinja expression

src/seclab_taskflow_agent/runner.py

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ class _Branch:
235235
label: str
236236
sink: list[ToolResult] = field(default_factory=list)
237237
# Typed multi-model output: when the task declares an ``outputs`` schema,
238-
# each branch's result is validated and the coerced value stored here
238+
# each branch's result is validated and the value stored here
239239
# (``None`` when the branch failed or violated the schema). ``output_set``
240240
# marks that validation ran so fan-in uses this value instead of decoding
241241
# the raw sink.
@@ -250,7 +250,7 @@ def _aggregate_fanin(branches: list[_Branch]) -> list[dict[str, Any]]:
250250
"result": <value>}``. ``result`` is the decoded final tool result of the
251251
branch (its raw text when not JSON, or ``None`` when the branch produced no
252252
tool result). When the task declares an ``outputs`` schema, ``result`` is
253-
the per-branch validated/coerced value instead (``None`` for a branch that
253+
the per-branch validated value instead (``None`` for a branch that
254254
failed or violated the schema). Used to expose multi-model / cross-product
255255
outputs to later tasks as ``outputs.<id>``.
256256
"""
@@ -375,7 +375,7 @@ def _capture_task_output(
375375
"""Capture a task's final tool result as a named typed output.
376376
377377
The task's output is its most recent tool result. When *schema* is
378-
non-empty the decoded value is validated/coerced against it (raising a
378+
non-empty the decoded value is validated against it (raising a
379379
clear error on mismatch); otherwise the decoded value is stored as-is,
380380
falling back to the raw text when it is not JSON. The result is exposed to
381381
later tasks as ``outputs.<output_id>``.
@@ -501,7 +501,8 @@ async def _build_prompts_to_run(
501501
502502
Raises:
503503
IndexError: If the legacy path has no previous tool result.
504-
ValueError: If the derived value is not valid JSON or not iterable.
504+
ValueError: If the legacy path's tool result is not valid JSON.
505+
TypeError: If the derived value is not iterable.
505506
"""
506507
outputs = outputs or {}
507508
prompts_to_run: list[str] = []
@@ -530,17 +531,22 @@ async def _build_prompts_to_run(
530531
raise IndexError("No last tool result available for repeat_prompt")
531532
iterable_result = decode_tool_result(last)
532533

534+
# Materialise before testing/iterating: an ``over:`` expression may
535+
# yield a one-shot iterator (e.g. Jinja ``map``/``select`` filters),
536+
# which is always truthy and would defeat the emptiness check and be
537+
# consumed by a single pass. ``list(...)`` also raises TypeError for a
538+
# genuinely non-iterable value.
533539
try:
534-
iter(iterable_result)
540+
items = list(iterable_result)
535541
except TypeError:
536542
logging.critical("repeat_prompt iterable is not iterable: %r", iterable_result)
537543
raise
538544

539-
if not iterable_result:
545+
if not items:
540546
await render_model_output("** 🤖❗repeat_prompt iterable is empty!\n")
541547
else:
542-
logging.debug("Rendering templated prompts for results: %s", iterable_result)
543-
for value in iterable_result:
548+
logging.debug("Rendering templated prompts for results: %s", items)
549+
for value in items:
544550
try:
545551
rendered_prompt = render_template(
546552
template_str=task_prompt,
@@ -1240,11 +1246,15 @@ async def _branch_record(tr: ToolResult) -> None:
12401246
f"** 🤖💾 Session saved: {session.session_id}\n"
12411247
f"** 🤖💡 Resume with: --resume {session.session_id}\n"
12421248
)
1243-
break
1249+
# Raise (rather than break) so run_main propagates the
1250+
# failure and the CLI exits non-zero, matching the other
1251+
# hard-failure paths; the session is already saved for
1252+
# --resume.
1253+
raise RuntimeError(f"Required task {task_name!r} did not complete")
12441254

12451255
# Capture this task's typed named output (M2). The task's
12461256
# output is its final tool result; when an ``outputs`` schema
1247-
# is declared it is validated/coerced before being stored under
1257+
# is declared it is validated before being stored under
12481258
# ``outputs.<id>`` for downstream tasks to consume by name. A
12491259
# declared output contract that the produced value violates is
12501260
# a hard failure, surfaced with the same session-saved / resume

tests/test_runner.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,31 @@ def test_repeat_with_empty_iterable(self):
455455
)
456456
assert prompts == []
457457

458+
def test_repeat_over_empty_generator_emits_empty_notice(self):
459+
"""An `over:` expression yielding an empty one-shot generator (e.g. a
460+
Jinja `map`/`select` filter) is detected as empty rather than silently
461+
producing zero prompts (a bare generator is always truthy)."""
462+
rendered: list[str] = []
463+
464+
async def _capture(text: str, **_kw: Any) -> None:
465+
rendered.append(text)
466+
467+
with patch("seclab_taskflow_agent.runner.render_model_output", _capture):
468+
prompts = asyncio.run(
469+
_build_prompts_to_run(
470+
task_prompt="do {{ result }}",
471+
repeat_prompt=True,
472+
store=ResultStore(),
473+
available_tools=_mock_available_tools(),
474+
global_variables={},
475+
inputs={},
476+
outputs={"items": []},
477+
over="outputs.items | map('upper')",
478+
)
479+
)
480+
assert prompts == []
481+
assert any("iterable is empty" in t for t in rendered)
482+
458483
def test_raises_index_error_when_no_last_result(self):
459484
"""IndexError when the store has no previous result."""
460485
with pytest.raises(IndexError):

tests/test_runner_integration.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
from typing import ClassVar
1818
from unittest.mock import MagicMock
1919

20+
import pytest
21+
2022
from seclab_taskflow_agent.models import (
2123
PersonalityDocument,
2224
TaskDefinition,
@@ -425,3 +427,20 @@ def test_schema_violation_tolerated_completion_any(self, monkeypatch, tmp_path):
425427
_run(monkeypatch, tmp_path, _taskflow(task), deploy_impl=self._mixed_deploy())
426428
# completion=any: one schema-valid branch is enough for task success.
427429
assert _session_data(tmp_path)["completed_tasks"][0]["result"] is True
430+
431+
432+
class TestMustCompleteFailure:
433+
"""A failed `must_complete` task aborts non-silently so the CLI exits non-zero."""
434+
435+
def test_must_complete_failure_raises_and_marks_failed(self, monkeypatch, tmp_path):
436+
async def failing_deploy(available_tools, agents, prompt, *, model=None, record_tool_result=None, **kw):
437+
return False # the task never completes
438+
439+
task = TaskDefinition(agents=["pkg.p"], user_prompt="hi", must_complete=True)
440+
with pytest.raises(RuntimeError, match="did not complete"):
441+
_run(monkeypatch, tmp_path, _taskflow(task), deploy_impl=failing_deploy)
442+
443+
# The session is still saved and marked failed (so --resume works).
444+
data = _session_data(tmp_path)
445+
assert data["finished"] is False
446+
assert "did not complete" in data["error"]

0 commit comments

Comments
 (0)