Skip to content

Commit 943afda

Browse files
Address PR #111 review round 3: runtime wrapper + doctor + markdown
Three more findings from PR #111 review — two P1s and one P2. The v0.20 third-party adapter surface now has parity across all three consumers (scan dispatcher, doctor introspection, markdown report) and the documented lenient/strict contract holds end-to-end. 4 new regression tests, 32 total in test_adapter_entry_point_discovery.py. P1 #1 — adapter runtime failures bypassed loaded_adapters.runtime_errors. The dispatcher in ``_load_sources`` called ``adapter.load(...)`` directly for ALL adapters. ``run_validated_adapter`` existed in ``inputs/adapter_validation.py`` but was never invoked during a real scan. A third-party adapter that raised at runtime aborted ``run_scan`` with the raw exception, no report was emitted, and ``--strict-plugins`` never saw the failure. Contradicted the documented "lenient mode records the failure, continues" contract. Fix: - ``_load_inputs`` now builds a ``third_party_records: dict[str, LoadedAdapter]`` map from discovery (one entry per valid third-party adapter, keyed by source_type) and threads it to ``_load_sources(..., third_party_records=...)``. - ``_load_sources`` (pass 1 / pass 2) checks each adapter's source_type against the map. Built-ins keep the direct ``adapter.load(...)`` call shape — a built-in raising is a scanner bug and must abort loudly. Third-party adapters route through ``run_validated_adapter`` instead, which catches every exception, captures it into ``LoadedAdapter.runtime_errors`` (and mirrors into the matching ``loaded_adapters[].runtime_errors`` dict), and returns ``None`` so the dispatcher skips ``_absorb``. - ``_invoke_per_source_adapter`` learned a ``third_party_record`` kwarg that switches to the wrapper. Built-in optional-source ``InputParseError`` handling is preserved on the built-in branch. Result: lenient-mode third-party crashes are captured, the scan completes, the report contains the failed adapter row with ``runtime_errors`` populated, and ``--strict-plugins`` exits 4 as documented. P1 #2 — doctor couldn't introspect manifests using third-party source types. ``inspect_sources`` (the doctor command's manifest- introspection entry point) called ``_load_sources`` against the global ``REGISTRY`` (which is intentionally builtin-only since the PR #111 P1 #1+#2 per-scan-clone refactor) and did NOT run adapter discovery. So a manifest with ``tool_sources[].type: demo_source`` that scanned cleanly now made ``doctor`` crash with ``ConfigError: No adapter registered for source type 'demo_source'``. Fix: - ``inspect_sources`` mirrors the ``_load_inputs`` pattern: build ``scan_registry = REGISTRY.clone()``, run ``discover_third_party_adapters(scan_registry, …)`` (honoring the same ``plugins_enabled`` env / override semantics), pass the per-scan registry and ``third_party_records`` to ``_load_sources``. - New ``plugins_enabled: bool | None = None`` kwarg defaults to ``None`` so the existing env var governs (no doctor CLI flag change required). - ``loaded_adapters`` surfaced in the doctor payload alongside ``policy_packs`` so an operator can confirm what was discovered without running a full scan. P2 #3 — Markdown reports hid adapter validation failures. ``report/markdown.py`` rendered ``loaded_policy_packs`` and ``loaded_plugins`` but had no equivalent ``loaded_adapters`` section. A ``load_failed`` adapter appeared in ``report.json`` but not in ``report.md`` — the default human report was blind to skipped third-party extensions. Fix: - New ``_append_loaded_adapters(lines, report)`` renders a ``## Loaded Adapters`` section listing each entry as ``- distribution version: source_type — \`validation_status\`` followed by per-error indented bullets (validation_error and runtime_error). Hidden when ``loaded_adapters`` is empty so clean repos see no extra noise. - Wired into the main rendering flow right after ``_append_loaded_plugins``. Regression tests (4 new in tests/test_adapter_entry_point_discovery.py): - ``test_runtime_error_in_third_party_adapter_captured_not_propagated`` — a third-party adapter that raises ``RuntimeError`` at runtime doesn't abort ``run_scan``; the failure lands in ``loaded_adapters[].runtime_errors``; the scan exits 0. - ``test_strict_plugins_exits_on_third_party_adapter_runtime_error`` — end-to-end via CliRunner: ``--strict-plugins`` elevates exit code 4 specifically on the new adapter-runtime-error path. - ``test_doctor_resolves_third_party_source_types`` — a manifest with ``type: demo_source`` and a matching third-party adapter is introspected successfully by ``inspect_sources``; the adapter runs; the doctor payload surfaces ``loaded_adapters[]``. - ``test_markdown_report_renders_loaded_adapters_section`` — a ``load_failed`` adapter shows up in ``report.md`` under the ``## Loaded Adapters`` heading with its status and validation error. Verification: 1268 tests pass; ruff clean; schema roundtrip --check exits 0; coverage 88.06%. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 14b943a commit 943afda

3 files changed

Lines changed: 400 additions & 7 deletions

File tree

src/agents_shipgate/cli/scan.py

Lines changed: 138 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -377,13 +377,30 @@ def _load_inputs(
377377
# ``monkeypatch.setitem(REGISTRY._adapters, …)`` still work.
378378
scan_registry = REGISTRY.clone()
379379
loaded_adapters: list[dict[str, Any]] = []
380-
discover_third_party_adapters(
380+
discovery_records = discover_third_party_adapters(
381381
scan_registry,
382382
plugins_enabled=plugins_enabled,
383383
loaded_adapters=loaded_adapters,
384384
)
385+
# v0.20 (PR #111 review follow-up #2): map of source_type → valid
386+
# LoadedAdapter record. Used by ``_load_sources`` to route
387+
# third-party adapter ``load()`` calls through
388+
# ``run_validated_adapter`` so runtime exceptions land in
389+
# ``loaded_adapters[].runtime_errors`` instead of crashing the
390+
# scan. Invalid records (validation_status != "valid") are
391+
# excluded: they never registered on ``scan_registry`` and so the
392+
# dispatcher will never reach them.
393+
third_party_records: dict[str, Any] = {
394+
record.adapter.source_type: record
395+
for record in discovery_records
396+
if record.adapter is not None
397+
}
385398
loaded_sources, artifact_bag = _load_sources(
386-
manifest, base_dir, verbose=verbose, registry=scan_registry
399+
manifest,
400+
base_dir,
401+
verbose=verbose,
402+
registry=scan_registry,
403+
third_party_records=third_party_records,
387404
)
388405
logger.debug(
389406
"loaded sources",
@@ -1246,7 +1263,25 @@ def run_scan(
12461263

12471264

12481265

1249-
def inspect_sources(*, config_path: Path, verbose: bool = False) -> dict[str, object]:
1266+
def inspect_sources(
1267+
*,
1268+
config_path: Path,
1269+
verbose: bool = False,
1270+
plugins_enabled: bool | None = None,
1271+
) -> dict[str, object]:
1272+
"""``doctor``'s manifest-introspection entry point.
1273+
1274+
v0.20 (PR #111 review fix follow-up #3): mirrors ``_load_inputs``'s
1275+
per-scan registry clone + adapter discovery so ``doctor`` can
1276+
inspect manifests that reference third-party source types. Before
1277+
this fix, the global ``REGISTRY`` was builtin-only (by design,
1278+
after the per-scan-registry refactor), so a manifest with
1279+
``tool_sources[].type: demo_source`` scanned fine but ``doctor``
1280+
raised ``ConfigError: No adapter registered``.
1281+
"""
1282+
1283+
from agents_shipgate.inputs.protocol import discover_third_party_adapters
1284+
12501285
manifest = load_manifest(config_path)
12511286
base_dir = config_path.resolve().parent
12521287
unresolved_sources = _resolve_source_paths(manifest, base_dir, config_path)
@@ -1264,7 +1299,30 @@ def inspect_sources(*, config_path: Path, verbose: bool = False) -> dict[str, ob
12641299
]
12651300
}
12661301
)
1267-
loaded_sources, artifact_bag = _load_sources(manifest, base_dir, verbose=verbose)
1302+
# v0.20 (PR #111 review follow-up #3): build a per-scan registry
1303+
# for ``doctor`` so it sees the same adapter set as ``scan``. The
1304+
# global ``REGISTRY`` is builtin-only by design after the
1305+
# per-scan-clone refactor; without this discovery step,
1306+
# third-party source types would be unresolvable here.
1307+
scan_registry = REGISTRY.clone()
1308+
loaded_adapters: list[dict[str, Any]] = []
1309+
discovery_records = discover_third_party_adapters(
1310+
scan_registry,
1311+
plugins_enabled=plugins_enabled,
1312+
loaded_adapters=loaded_adapters,
1313+
)
1314+
third_party_records: dict[str, Any] = {
1315+
record.adapter.source_type: record
1316+
for record in discovery_records
1317+
if record.adapter is not None
1318+
}
1319+
loaded_sources, artifact_bag = _load_sources(
1320+
manifest,
1321+
base_dir,
1322+
verbose=verbose,
1323+
registry=scan_registry,
1324+
third_party_records=third_party_records,
1325+
)
12681326
adk_artifacts = artifact_bag.get("google_adk", GoogleAdkArtifacts)
12691327
langchain_artifacts = artifact_bag.get("langchain", LangChainArtifacts)
12701328
crewai_artifacts = artifact_bag.get("crewai", CrewAiArtifacts)
@@ -1312,6 +1370,11 @@ def inspect_sources(*, config_path: Path, verbose: bool = False) -> dict[str, ob
13121370
else None
13131371
),
13141372
"policy_packs": [pack.model_dump(mode="json") for pack in policy_packs.loaded],
1373+
# v0.20 (PR #111 review follow-up #3): surface third-party
1374+
# adapter discovery results in the doctor payload so the
1375+
# operator can confirm which extensions were loaded (or why
1376+
# they were skipped) without running a full scan.
1377+
"loaded_adapters": loaded_adapters,
13151378
"baseline": _default_baseline_status(base_dir),
13161379
"warnings": warnings,
13171380
"unresolved_sources": unresolved_sources,
@@ -1399,6 +1462,7 @@ def _load_sources(
13991462
*,
14001463
verbose: bool,
14011464
registry: Any = None,
1465+
third_party_records: dict[str, Any] | None = None,
14021466
) -> tuple[list[LoadedToolSource], ArtifactBag]:
14031467
"""Dispatch every adapter through the supplied ``registry``.
14041468
@@ -1430,9 +1494,22 @@ def _load_sources(
14301494
``_load_inputs`` (notably the legacy tests in
14311495
``tests/test_adapter_registry.py``). New code should always pass
14321496
a per-scan registry.
1497+
1498+
v0.20 (PR #111 review fix follow-up #2): ``third_party_records``
1499+
maps each validated third-party ``source_type`` to its
1500+
``LoadedAdapter`` record (from ``discover_third_party_adapters``).
1501+
When set, the dispatcher routes those adapters through
1502+
``run_validated_adapter`` so any exception during their
1503+
``load()`` call is captured into
1504+
``loaded_adapters[].runtime_errors`` and the scan continues in
1505+
lenient mode (or trips ``--strict-plugins`` exit 4 in strict
1506+
mode). Built-in adapters keep the direct call shape — a built-in
1507+
raising means the scanner itself is broken and must abort loudly.
14331508
"""
14341509
if registry is None:
14351510
registry = REGISTRY
1511+
if third_party_records is None:
1512+
third_party_records = {}
14361513
per_source_loaded: list[LoadedToolSource] = []
14371514
per_scan_loaded: list[LoadedToolSource] = []
14381515
bag = ArtifactBag()
@@ -1447,9 +1524,20 @@ def _load_sources(
14471524
adapter = registry.require(source.type)
14481525
if adapter.scope != "per_source":
14491526
continue
1527+
third_party_record = third_party_records.get(source.type)
14501528
result = _invoke_per_source_adapter(
1451-
adapter, source, base_dir, manifest, verbose=verbose
1529+
adapter,
1530+
source,
1531+
base_dir,
1532+
manifest,
1533+
verbose=verbose,
1534+
third_party_record=third_party_record,
14521535
)
1536+
if result is None:
1537+
# Third-party adapter raised at runtime; the wrapper
1538+
# captured the failure into runtime_errors and we skip
1539+
# absorbing the (None) result.
1540+
continue
14531541
_absorb(result, source.type, per_source_loaded, bag, adapter)
14541542

14551543
# Pass 2 — every per-scan adapter fires once, in registry order.
@@ -1458,7 +1546,22 @@ def _load_sources(
14581546
# configured) and manifest-only adapters (openai_api,
14591547
# anthropic_api, n8n).
14601548
for adapter in registry.per_scan_adapters():
1461-
result = adapter.load(None, base_dir, manifest)
1549+
third_party_record = third_party_records.get(adapter.source_type)
1550+
if third_party_record is not None:
1551+
from agents_shipgate.inputs.adapter_validation import (
1552+
run_validated_adapter,
1553+
)
1554+
1555+
result = run_validated_adapter(
1556+
third_party_record,
1557+
source=None,
1558+
base_dir=base_dir,
1559+
manifest=manifest,
1560+
)
1561+
if result is None:
1562+
continue
1563+
else:
1564+
result = adapter.load(None, base_dir, manifest)
14621565
_absorb(result, adapter.source_type, per_scan_loaded, bag, adapter)
14631566

14641567
return per_source_loaded + per_scan_loaded, bag
@@ -1552,7 +1655,35 @@ def _invoke_per_source_adapter(
15521655
manifest: AgentsShipgateManifest,
15531656
*,
15541657
verbose: bool,
1555-
) -> LoadedAdapterResult:
1658+
third_party_record: Any = None,
1659+
) -> LoadedAdapterResult | None:
1660+
"""Invoke a per_source adapter and return its result.
1661+
1662+
For **built-in** adapters: catch ``InputParseError`` only when the
1663+
source is marked ``optional`` (returning a warning-only stub);
1664+
any other exception propagates. A built-in raising means the
1665+
scanner is broken and must abort loudly.
1666+
1667+
For **third-party** adapters (``third_party_record`` is the
1668+
matching ``LoadedAdapter``): route through
1669+
``run_validated_adapter``, which captures ALL exceptions into the
1670+
record's ``runtime_errors`` list and returns ``None``. Returning
1671+
``None`` signals the caller to skip ``_absorb`` for this source —
1672+
the scan continues in lenient mode and ``--strict-plugins`` sees
1673+
the runtime error on exit.
1674+
"""
1675+
1676+
if third_party_record is not None:
1677+
from agents_shipgate.inputs.adapter_validation import (
1678+
run_validated_adapter,
1679+
)
1680+
1681+
return run_validated_adapter(
1682+
third_party_record,
1683+
source=source,
1684+
base_dir=base_dir,
1685+
manifest=manifest,
1686+
)
15561687
try:
15571688
return adapter.load(source, base_dir, manifest)
15581689
except InputParseError:

src/agents_shipgate/report/markdown.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ def render_markdown_report(report: ReadinessReport) -> str:
104104
_append_source_warnings(lines, report)
105105
_append_loaded_policy_packs(lines, report)
106106
_append_loaded_plugins(lines, report)
107+
_append_loaded_adapters(lines, report)
107108
_append_tool_surface(lines, report)
108109
_append_action_surface_diff(lines, report)
109110
_append_tool_surface_diff(lines, report)
@@ -461,6 +462,44 @@ def _append_loaded_policy_packs(lines: list[str], report: ReadinessReport) -> No
461462
lines.append("")
462463

463464

465+
def _append_loaded_adapters(lines: list[str], report: ReadinessReport) -> None:
466+
"""v0.20 (PR #111 review follow-up #4): render third-party adapter
467+
provenance in the human-readable report.
468+
469+
Before this section landed, ``load_failed`` and other invalid
470+
adapters appeared in ``report.json`` only — the default human
471+
report was blind to skipped third-party extensions. Show the
472+
validation status prominently so the reviewer sees what was
473+
skipped (and why) without opening the JSON.
474+
"""
475+
476+
adapters = getattr(report, "loaded_adapters", None) or []
477+
if not adapters:
478+
return
479+
lines.extend(["## Loaded Adapters", ""])
480+
for adapter in adapters:
481+
distribution = adapter.get("distribution") or "unknown distribution"
482+
version = adapter.get("version")
483+
source_type = adapter.get("source_type") or "unknown source_type"
484+
status = adapter.get("validation_status") or "unknown"
485+
suffix = f" {version}" if version else ""
486+
head = (
487+
f"- {_safe_markdown_text(distribution)}"
488+
f"{_safe_markdown_text(suffix)}: "
489+
f"{_safe_markdown_text(source_type)} — "
490+
f"`{_safe_markdown_text(status)}`"
491+
)
492+
lines.append(head)
493+
# Surface every validation_error / runtime_error on its own
494+
# indented bullet so a reviewer scanning the report cannot
495+
# miss an adapter that failed to load or crashed at runtime.
496+
for err in adapter.get("validation_errors") or []:
497+
lines.append(f" - validation_error: {_safe_markdown_text(err)}")
498+
for err in adapter.get("runtime_errors") or []:
499+
lines.append(f" - runtime_error: {_safe_markdown_text(err)}")
500+
lines.append("")
501+
502+
464503
def _append_tool_surface(lines: list[str], report: ReadinessReport) -> None:
465504
surface = report.tool_surface
466505
lines.extend(

0 commit comments

Comments
 (0)