Implemented
2026-03-17
APME's remediation engine needs LLM integration for Tier 2 (AI-proposable) violations. The initial implementation uses Abbenay as the AI backend via its abbenay-client Python package (import: abbenay_grpc). However, the engine should not be tightly coupled to any specific LLM provider or client library because:
-
Testability. Unit tests for the remediation engine should not require a running Abbenay daemon or network access. A mock provider must be trivially substitutable.
-
Provider flexibility. While Abbenay abstracts multiple LLM providers behind a single gRPC interface, some deployments may want to call an LLM SDK directly (OpenAI, Anthropic) without running a daemon, or use a different abstraction layer entirely.
-
Optional dependency. The
abbenay-clientpackage is an optional install (pip install apme-engine[ai]). The core engine must function without it. Importingabbenay_grpcat module level would make it a hard dependency. -
Single coupling point. If the Abbenay client API changes (method signatures, async behavior, response format), the blast radius should be exactly one file, not spread across the engine and CLI.
Define AIProvider as a Python typing.Protocol with a single async method propose_fix(). The engine depends only on this protocol. Concrete implementations live in separate modules.
class AIProvider(Protocol):
async def propose_fix(
self,
violation: ViolationDict,
file_content: str,
*,
model: str | None = None,
feedback: str | None = None,
) -> AIProposal | None: ...The default implementation is AbbenayProvider in src/apme_engine/remediation/abbenay_provider.py -- the only file in the codebase that imports abbenay_grpc.
The engine accepts ai_provider: AIProvider | None = None in its constructor. When None, AI escalation is skipped entirely (Tier 2 violations remain as "AI-candidate" in the report).
from abbenay_grpc import AbbenayClient
class RemediationEngine:
def __init__(self, ..., abbenay_host="localhost", abbenay_port=50057):
self._client = AbbenayClient(host=abbenay_host, port=abbenay_port)Rejected. Hard couples the engine to abbenay_grpc. Cannot test without mocking at the import level. Makes abbenay-client a de-facto hard dependency. Changing the client API requires modifying engine internals.
from abc import ABC, abstractmethod
class AIProvider(ABC):
@abstractmethod
async def propose_fix(self, ...) -> AIProposal | None: ...Rejected. Requires concrete implementations to inherit from the base class. A Protocol is structurally typed -- any object with a matching propose_fix method satisfies the contract, including simple lambdas and mock objects in tests. This is more Pythonic and lighter weight.
AI_PROVIDERS = {
"abbenay": AbbenayProvider,
"openai": DirectOpenAIProvider,
"mock": MockProvider,
}
provider = AI_PROVIDERS[config["ai_provider"]]()Rejected. Over-engineered for the current requirement (one real provider). Adds a registry/factory layer that provides no benefit until there are multiple production providers. The Protocol approach supports future providers without any factory -- just write a class that satisfies the protocol.
- One coupling point. Only
abbenay_provider.pyimportsabbenay_grpc. The rest of the codebase is provider-agnostic. - Testable. Tests use a
MockAIProviderthat returns cannedAIProposalobjects. No network, no daemon, no mocking library gymnastics. - Optional dependency.
abbenay_grpcis only imported insideAbbenayProvider.__init__(), wrapped in a try/except with a clear error message. The core package installs and runs without it. - Future-proof. Adding a
DirectOpenAIProviderorOllamaProviderrequires writing one file that satisfies the protocol. No engine changes, no factory registration.
- Indirection. One extra layer between the engine and the LLM client. Acceptable given the benefits; the protocol is a single method.
- Async boundary. The protocol method is
async. TheGraphRemediationEngine.remediate()is also async, so no bridging is needed.
The AIProvider protocol has been updated to graph-native:
propose_fix()is replaced bypropose_node_fix(context: AINodeContext, *, model=None) -> AINodeFix | None— operates on individual graph nodes rather than full filesAIProposalandAIPatchare replaced byAINodeFix(single-node fix) andAINodeProposal(result with before/after YAML)AINodeContext(inai_context.py) bundles the node's YAML, violations, parent context, sibling snippets, and best-practice guidance from theContentGraph- Engine wiring:
GraphRemediationEngine.__init__(ai_provider: AIProvider | None = None) - Primary resolves the provider via
_resolve_ai_provider()with graceful degradation (returnsNonewhen prerequisites are missing: no daemon address, no model, or noabbenay_grpcinstall); if the daemon is unreachable at runtime,propose_node_fix()raises and the graph engine catches/skips
AIProviderprotocol andAINodeFixdataclass:src/apme_engine/remediation/ai_provider.pyAINodeContextbuilder:src/apme_engine/remediation/ai_context.pyAbbenayProvider:src/apme_engine/remediation/abbenay_provider.py- Full design:
docs/design/DESIGN_AI_ESCALATION.md