refactor: replace if/elif classifier chain with registry and unify guardrail helpers#2964
Conversation
…ardrail helpers - Convert _classify_service_instance 900-line if/elif chain (46 branches) into individual _classify_<name> functions and a _CLASSIFIERS dispatch dict; adding a new integration now requires one function + one dict entry instead of editing the monolith - Extract duplicate guardrail application logic from 6 call sites into app/guardrails/apply.py with three focused helpers: apply_guardrails_to_messages, apply_guardrails_to_text, and apply_guardrails_to_converse_payload (moved from bedrock_converse.py) - Update all callers in llm_client.py, agent_llm_client.py, chat/agent.py, llm_cli/runner.py and fix corresponding test imports Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Greptile code reviewThis repo uses Greptile for automated review. Before merge, aim for Confidence Score: 5/5 with zero unresolved review threads — see CONTRIBUTING.md. Run a review — add a PR comment with: Give it ~5-10 minutes (sometimes longer) for results, then fix feedback and re-trigger until you reach Confidence Score: 5/5. Optional: automate with the greploop skill. |
- Auto-format _catalog_impl.py (ruff formatter had line-length issues with the new _classify_pagerduty and _classify_dagster one-liners) - Fix ImportError in test_bedrock_converse.py: apply_guardrails_to_converse_payload moved to app.guardrails.apply in the previous commit Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Greptile SummaryThis PR refactors two large orthogonal areas: (1) replaces a 900-line
Confidence Score: 4/5Safe to merge; the guardrail and catalog changes are well-isolated, and the existing test suite covers the primary code paths. The refactoring is clean and the three new guardrail helpers are functionally equivalent to the inline code they replace. Two minor concerns keep this from a perfect score: the app/guardrails/apply.py (system-prompt guard consistency) and app/integrations/_catalog_impl.py (unknown-key fallback behavior). Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[LLM call site] --> B{Which client?}
B -->|LLMClient / BedrockLLMClient Anthropic| C[_normalize_messages]
B -->|OpenAILLMClient| D[_normalize_messages_openai]
B -->|BedrockLLMClient Converse| E[_normalize_messages]
B -->|CLIBackedLLMClient| F[flatten_messages_to_prompt]
B -->|BedrockConverseAgentClient| G[to_converse_messages]
B -->|chat agent _prepare_messages| H[messages_to_invocation_dicts]
C --> C2[apply_guardrails_to_messages]
D --> D2[apply_guardrails_to_messages]
E --> E2[apply_guardrails_to_messages]
F --> F2[apply_guardrails_to_text]
G --> G2[apply_guardrails_to_converse_payload]
H --> H2[apply_guardrails_to_messages]
subgraph apply.py
C2
D2
E2
F2
G2
H2
end
C2 --> Z[get_guardrail_engine]
D2 --> Z
E2 --> Z
F2 --> Z
G2 --> Z
H2 --> Z
Z --> |is_active = False| PASS[Return unchanged]
Z --> |is_active = True| APPLY[engine.apply per content field]
APPLY --> |block rule| ERR[GuardrailBlockedError]
APPLY --> |redact rule| REDACTED[Redacted payload]
REDACTED --> API[Send to LLM provider]
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A[LLM call site] --> B{Which client?}
B -->|LLMClient / BedrockLLMClient Anthropic| C[_normalize_messages]
B -->|OpenAILLMClient| D[_normalize_messages_openai]
B -->|BedrockLLMClient Converse| E[_normalize_messages]
B -->|CLIBackedLLMClient| F[flatten_messages_to_prompt]
B -->|BedrockConverseAgentClient| G[to_converse_messages]
B -->|chat agent _prepare_messages| H[messages_to_invocation_dicts]
C --> C2[apply_guardrails_to_messages]
D --> D2[apply_guardrails_to_messages]
E --> E2[apply_guardrails_to_messages]
F --> F2[apply_guardrails_to_text]
G --> G2[apply_guardrails_to_converse_payload]
H --> H2[apply_guardrails_to_messages]
subgraph apply.py
C2
D2
E2
F2
G2
H2
end
C2 --> Z[get_guardrail_engine]
D2 --> Z
E2 --> Z
F2 --> Z
G2 --> Z
H2 --> Z
Z --> |is_active = False| PASS[Return unchanged]
Z --> |is_active = True| APPLY[engine.apply per content field]
APPLY --> |block rule| ERR[GuardrailBlockedError]
APPLY --> |redact rule| REDACTED[Redacted payload]
REDACTED --> API[Send to LLM provider]
|
| msg = {**msg, "content": engine.apply(content)} | ||
| guarded.append(msg) | ||
|
|
||
| guarded_system = engine.apply(system) if system else system |
There was a problem hiding this comment.
apply_guardrails_to_messages guards the system prompt with a falsy check (if system), so an empty-string system prompt skips the engine entirely. apply_guardrails_to_converse_payload (line 84) uses the stricter if system is not None instead. Neither path is broken today because callers always pass None or a non-empty string, but the inconsistency is a latent trap for future callers who pass system="" expecting it to be scanned.
| guarded_system = engine.apply(system) if system else system | |
| guarded_system = engine.apply(system) if system is not None else None |
| Returns ``(None, None)`` when the instance is invalid or should be skipped | ||
| (e.g. required field missing). The returned ``resolved_key`` is usually |
There was a problem hiding this comment.
Unknown-key passthrough is a silent behavioral change
The old monolithic function had no explicit catch-all: an unrecognized key would fall through and return None (causing a TypeError at the unpacking site, which would be visible). The new fallback silently returns {"credentials": credentials, "integration_id": record_id} — a non-None tuple — so the instance passes the if flat_view is None or flat_key is None: continue gate and lands in resolved[key] with raw credentials attached. If an integration type is registered in the database before its classifier ships, its credentials will now appear in the resolved catalog under their raw key instead of being filtered out. Consider returning (None, None) here and reserving the passthrough for an explicit opt-in.
|
Nice work 🙌 🫡 |
…ey visibility - apply_guardrails_to_messages: change `if system` to `if system is not None` so an empty string system prompt is treated the same way as in apply_guardrails_to_converse_payload (both helpers now consistent) - _classify_service_instance: replace silent passthrough for unregistered service keys with a logger.warning so unknown integrations surface in Sentry/logs rather than being swallowed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nknown-key visibility" This reverts commit 5985c37.
|
🌮 @0xpaulx's PR: showed up unannounced, improved everything, left zero bugs. Just like a perfect taco. 🌮 👋 Join us on Discord - OpenSRE : hang out, contribute, or hunt for features and issues. Everyone's welcome. |

Summary
Catalog classifier registry —
_classify_service_instancein_catalog_impl.pywas a 900-line function with 46 sequentialif key == "X":branches doing identical try/validate/return logic. Each branch is now its own_classify_<name>function and a_CLASSIFIERS: dict[str, _ClassifyFn]dispatches to it. Adding a new integration requires one function + one dict entry instead of editing the monolith.Unified guardrail helpers — the pattern
get engine → check is_active → loop messages → applywas copy-pasted inline across 6 call sites (llm_client.py×3,agent_llm_client.py,chat/agent.py,llm_cli/runner.py) with slight variations. Extracted intoapp/guardrails/apply.pywith three focused helpers:apply_guardrails_to_messages,apply_guardrails_to_text, andapply_guardrails_to_converse_payload(moved frombedrock_converse.py). A guardrail contract change now requires editing one file.Zombie directory removal — six directories (
app/nodes/,app/delivery/,app/agents/,app/correlation/,app/hermes/,app/alerts/) contained only__pycache__after previous refactors moved their source files. Deleted to avoid confusing new contributors.Test plan
make lint— passes cleanmake typecheck— 794 source files, no issuesuv run pytest tests/test_guardrails/ tests/services/test_llm_client.py tests/services/test_agent_llm_client.py tests/integrations/ tests/agent/— all passmake test-cov— 9,273 tests pass (live OpenAI routing failures are pre-existing rate-limit issues, unrelated to this change)🤖 Generated with Claude Code