Skip to content

Commit b96636f

Browse files
authored
feat: group preflight warnings into one tree block per check (#848)
* test: pin aggregated preflight warning output A run with N unguaranteed fields emits N near-identical multi-line warnings, each repeating the full remedy paragraph. Pin the intended contract: one warning record per check group, fields grouped per action on one line, remedy stated once, counts in the header. Signed-off-by: Muizz Lateef <lateefmuizz@gmail.com> * test: capture preflight warnings via propagation-forcing helper The grouping tests must force agent_actions logger propagation back on for the capture window — the logging bridge disables it — matching the established wiring-test idiom. Signed-off-by: Muizz Lateef <lateefmuizz@gmail.com> * feat: group preflight warnings into one tree block per check A run against a real project emitted 30+ near-identical multi-line warnings — one full paragraph per unguaranteed field, each repeating the same remedy. The warning wall buried both the signal and the two non-dag-fit findings mixed into it. The dag-fit scanner now returns missing fields grouped per consumer action, and the preflight service renders each warn-check's findings as a single tree-formatted warning: a header with field/action counts, one line per action, the remedy stated once. The prompt-contract, tool-passthrough, and resolution warnings route through the same renderer, so every preflight group reads as one scannable block. Signed-off-by: Muizz Lateef <lateefmuizz@gmail.com> --------- Signed-off-by: Muizz Lateef <lateefmuizz@gmail.com>
1 parent 8f4fe14 commit b96636f

5 files changed

Lines changed: 169 additions & 28 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
kind: Enhancement or New Feature
2+
body: "Preflight warnings now print as one grouped, tree-formatted block per check (dag-fit, prompt-contract, tool-passthrough, resolution) with counts in the header, fields grouped per action, and the remedy stated once — instead of one full paragraph per field. A run that previously emitted 30+ near-identical warning paragraphs now shows a dozen scannable lines."
3+
time: 2026-08-08T01:30:00Z

agent_actions/services/preflight_service.py

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from agent_actions.utils.constants import NON_PROMPT_ACTION_KINDS
2020
from agent_actions.utils.udf_management.registry import get_udf_metadata
2121
from agent_actions.validation.dag_schema_fit_validator import (
22+
DAG_FIT_REMEDY,
2223
find_dag_schema_compatibility_gaps,
2324
)
2425
from agent_actions.validation.dep_observe_validator import find_missing_observe_deps
@@ -113,14 +114,17 @@ def validate(self) -> None:
113114
).resolve_all()
114115
resolution_result.raise_if_invalid()
115116

116-
for warning in resolution_result.warnings:
117-
logger.warning("Pre-flight: %s", warning.message)
117+
self._warn_findings(
118+
"resolution", [warning.message for warning in resolution_result.warnings]
119+
)
118120

119121
# 6. Cross-check prompt refs against each producer's required set
120-
for finding in find_unguarded_required_refs(
121-
self._collect_prompts(), self._collect_producing_schemas()
122-
):
123-
logger.warning("Pre-flight: %s", finding)
122+
self._warn_findings(
123+
"prompt-contract",
124+
find_unguarded_required_refs(
125+
self._collect_prompts(), self._collect_producing_schemas()
126+
),
127+
)
124128

125129
# 7. Cross-check kind:tool passthrough UDFs against strict output schemas
126130
self._warn_tool_passthrough_risks()
@@ -198,8 +202,10 @@ def _collect_tool_passthrough_inputs(self) -> dict[str, dict[str, Any]]:
198202

199203
def _warn_tool_passthrough_risks(self) -> None:
200204
"""Warn when a kind:tool UDF passes upstream dicts through a strict schema."""
201-
for finding in find_passthrough_schema_risks(self._collect_tool_passthrough_inputs()):
202-
logger.warning("Pre-flight: %s", finding)
205+
self._warn_findings(
206+
"tool-passthrough",
207+
find_passthrough_schema_risks(self._collect_tool_passthrough_inputs()),
208+
)
203209

204210
def _collect_tool_required_field_inputs(self) -> dict[str, dict[str, Any]]:
205211
"""UDF source + compiled required list per kind:tool action.
@@ -247,5 +253,43 @@ def _check_tool_conditional_required_field_risks(self) -> None:
247253

248254
def _warn_dag_schema_compatibility_gaps(self) -> None:
249255
"""Warn when a tool consumer's required output field is neither guaranteed by an upstream producer nor declared as synthesized via `defaults:`."""
250-
for finding in find_dag_schema_compatibility_gaps(self.action_configs):
251-
logger.warning("Pre-flight: %s", finding)
256+
gaps = find_dag_schema_compatibility_gaps(self.action_configs)
257+
if not gaps:
258+
return
259+
total = sum(len(fields) for fields in gaps.values())
260+
self._warn_findings(
261+
"dag-fit",
262+
[f"{action}: {', '.join(fields)}" for action, fields in gaps.items()],
263+
header=(
264+
f"dag-fit — {total} required field(s) with no upstream guarantee "
265+
f"across {len(gaps)} action(s)"
266+
),
267+
remedy=DAG_FIT_REMEDY,
268+
)
269+
270+
@staticmethod
271+
def _warn_findings(
272+
label: str,
273+
findings: list[str],
274+
header: str | None = None,
275+
remedy: str | None = None,
276+
) -> None:
277+
"""Emit one grouped warning for a check's findings instead of one per finding.
278+
279+
Renders a tree so a run's warning wall reads as a few scannable blocks:
280+
281+
Pre-flight: dag-fit — 5 required field(s) ... across 2 action(s)
282+
├─ flatten: category, key, steps
283+
├─ assemble: id, items
284+
└─ Fix: mark the field optional in the consumer schema, ...
285+
"""
286+
if not findings:
287+
return
288+
items = list(findings)
289+
if remedy:
290+
items.append(f"Fix: {remedy}")
291+
body = "\n".join(
292+
f" {'└─' if i == len(items) - 1 else '├─'} {item}" for i, item in enumerate(items)
293+
)
294+
head = header or f"{label}{len(findings)} warning(s)"
295+
logger.warning("Pre-flight: %s\n%s", head, body)

agent_actions/validation/_MANIFEST.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ decoders to schema validators and preflight checks.
2424
| `base_validator.py` | Module | `BaseValidator` base class with helper assertions for validators. | `validation` |
2525
| `bus_namespace_validator.py` | Module | `find_unknown_bus_namespaces`: AST scan flagging tool-UDF reads of `data.get("X")` / `data["X"]` where X is not a runtime bus namespace (action name or framework key), catching silent namespace typos at `validate-udfs`. | `validation` |
2626
| `config_validator.py` | Module | Central config parser/validator used across startup flows. | `configuration`, `validation` |
27-
| `dag_schema_fit_validator.py` | Module | `find_dag_schema_compatibility_gaps`: per producer/consumer edge in the workflow DAG, warns when a tool consumer's required output field is neither guaranteed at any position in an upstream producer's compiled schema nor declared as synthesized via `defaults:` on the action. Symmetric two-level descent on both sides (root, `field.`, `field[].`) — no UDF source reading. Wired at `PreflightService._warn_dag_schema_compatibility_gaps` (spec 592 Phase 2, warn-only). | `validation` |
27+
| `dag_schema_fit_validator.py` | Module | `find_dag_schema_compatibility_gaps`: per producer/consumer edge in the workflow DAG, reports `{consumer: [missing fields]}` where a tool consumer's required output field is neither guaranteed at any position in an upstream producer's compiled schema nor declared as synthesized via `defaults:` on the action; `DAG_FIT_REMEDY` carries the shared fix text. Symmetric two-level descent on both sides (root, `field.`, `field[].`) — no UDF source reading. Wired at `PreflightService._warn_dag_schema_compatibility_gaps` (spec 592 Phase 2, warn-only). | `validation` |
2828
| `dep_observe_validator.py` | Module | `find_missing_observe_deps`: preflight mirror of the fatal runtime check that every declared dependency has an observe/passthrough field reference. | `validation` |
2929
| `path_validator.py` | Module | Path validation utilities conforming to BaseValidator interface. | `validation` |
3030
| `prompt_ast.py` | Module | Jinja2 AST parser for extracting template variables. | `prompt_generation` |

agent_actions/validation/dag_schema_fit_validator.py

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -82,17 +82,26 @@ def _upstream_edges(
8282
return resolved
8383

8484

85+
DAG_FIT_REMEDY = (
86+
"mark the field optional in the consumer schema, mark it required at the "
87+
"producing position upstream, or declare it with `defaults:` on the consuming "
88+
"action — records missing a required field are rejected at runtime."
89+
)
90+
91+
8592
def find_dag_schema_compatibility_gaps(
8693
action_configs: dict[str, dict[str, Any]],
87-
) -> list[str]:
88-
"""One finding per required output field on a tool consumer that is neither
89-
guaranteed by an upstream producer nor declared as synthesized via
90-
`defaults:` on the action.
94+
) -> dict[str, list[str]]:
95+
"""Per tool consumer, the required output fields that are neither guaranteed
96+
by an upstream producer nor declared as synthesized via `defaults:`.
97+
98+
Returns ``{consumer_action: [sorted missing fields]}`` for the caller to
99+
render as one grouped warning (remedy: ``DAG_FIT_REMEDY``).
91100
92101
Warn-only for Phase 2 of spec 592. Phase 4 flips this fatal and removes
93102
the sibling `find_conditional_required_field_risks` scanner it subsumes.
94103
"""
95-
findings: list[str] = []
104+
gaps: dict[str, list[str]] = {}
96105
for consumer_name, consumer in action_configs.items():
97106
if consumer.get("kind") != "tool":
98107
continue
@@ -118,15 +127,8 @@ def find_dag_schema_compatibility_gaps(
118127
if isinstance(producer_schema, dict):
119128
guaranteed |= _required_fields_two_level(producer_schema)
120129

121-
for field in sorted(implicit_inputs - guaranteed):
122-
findings.append(
123-
f"dag-fit: {consumer_name} output requires '{field}' but no upstream "
124-
f"producer guarantees it (declared in properties without being listed "
125-
f"as required), and no `defaults:` entry declares it synthesized on "
126-
f"this action. Runtime schema validation will reject records where the "
127-
f"UDF does not emit it. Fix one of: mark '{field}' optional in the "
128-
f"consumer schema; mark it required at the producing position upstream; "
129-
f"or add `defaults: {{{field}: <value>}}` on this action."
130-
)
131-
132-
return findings
130+
missing = sorted(implicit_inputs - guaranteed)
131+
if missing:
132+
gaps[consumer_name] = missing
133+
134+
return gaps
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Preflight warnings must aggregate per check, not print one paragraph per field.
2+
3+
A run with N unguaranteed fields used to emit N near-identical multi-line
4+
warnings, each repeating the full remedy. The contract here: one warning
5+
record per check group, fields grouped by action, remedy stated once.
6+
"""
7+
8+
import logging
9+
10+
from agent_actions.services.preflight_service import PreflightService
11+
12+
_LOGGER = "agent_actions.services.preflight_service"
13+
14+
15+
def _capture_dagfit(configs, caplog):
16+
"""Run the dag-fit warn path with root propagation forced on for capture."""
17+
aa_logger = logging.getLogger("agent_actions")
18+
original = aa_logger.propagate
19+
aa_logger.propagate = True
20+
try:
21+
with caplog.at_level(logging.WARNING, logger=_LOGGER):
22+
_service(configs)._warn_dag_schema_compatibility_gaps()
23+
finally:
24+
aa_logger.propagate = original
25+
return [r for r in caplog.records if "dag-fit" in r.getMessage()]
26+
27+
28+
def _service(action_configs):
29+
return PreflightService(
30+
agent_name="wf",
31+
action_configs=action_configs,
32+
project_root=None,
33+
workflow_config_path="wf.yml",
34+
verify_keys=False,
35+
)
36+
37+
38+
def _tool(required, properties=None):
39+
props = properties or {f: {"type": "string"} for f in required}
40+
return {
41+
"kind": "tool",
42+
"dependencies": [],
43+
"json_output_schema": {
44+
"type": "object",
45+
"properties": props,
46+
"required": list(required),
47+
},
48+
}
49+
50+
51+
class TestDagFitGrouping:
52+
def test_one_warning_record_for_the_whole_group(self, caplog):
53+
configs = {
54+
"flatten": _tool(["category", "key"]),
55+
"assemble": _tool(["id", "items"]),
56+
}
57+
dagfit = _capture_dagfit(configs, caplog)
58+
assert len(dagfit) == 1, (
59+
f"expected ONE aggregated dag-fit warning, got {len(dagfit)} records"
60+
)
61+
62+
def test_fields_grouped_per_action_on_one_line(self, caplog):
63+
configs = {
64+
"flatten": _tool(["category", "key", "steps"]),
65+
"assemble": _tool(["id", "items"]),
66+
}
67+
msg = _capture_dagfit(configs, caplog)[0].getMessage()
68+
assert "flatten: category, key, steps" in msg, msg
69+
assert "assemble: id, items" in msg, msg
70+
71+
def test_remedy_stated_once_not_per_field(self, caplog):
72+
configs = {
73+
"flatten": _tool(["category", "key", "steps"]),
74+
"assemble": _tool(["id", "items"]),
75+
}
76+
combined = "\n".join(r.getMessage() for r in _capture_dagfit(configs, caplog))
77+
assert combined.count("optional in the consumer schema") == 1, combined
78+
79+
def test_header_carries_counts(self, caplog):
80+
configs = {
81+
"flatten": _tool(["category", "key", "steps"]),
82+
"assemble": _tool(["id", "items"]),
83+
}
84+
msg = _capture_dagfit(configs, caplog)[0].getMessage()
85+
first_line = msg.splitlines()[0]
86+
assert "5" in first_line and "2" in first_line, (
87+
f"header should carry field and action counts: {first_line!r}"
88+
)
89+
90+
def test_quiet_when_no_gaps(self, caplog):
91+
configs = {"ok": _tool([])}
92+
assert _capture_dagfit(configs, caplog) == []

0 commit comments

Comments
 (0)