new feature: External guardrails/judges
This commit is contained in:
41
tests/migration/test_external_guardrails_judges_spi.py
Normal file
41
tests/migration/test_external_guardrails_judges_spi.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
import asyncio, threading
|
||||
from agent_framework.guardrails.base import RailDecision
|
||||
from agent_framework.guardrails.parallel_executor import ParallelRailExecutor
|
||||
from agent_framework.guardrails.config_loader import load_guardrails_config
|
||||
from agent_framework.judges.judge import JudgePipeline, JudgeResult
|
||||
|
||||
|
||||
def test_contas_config_substitui_policies_tim_por_extensoes():
|
||||
bundle=load_guardrails_config('config/guardrails.yaml')
|
||||
codes=[r.code for r in bundle.output_rails]
|
||||
assert 'TIM_OOS' in codes and 'TIM_AOFERTA' in codes and 'TIM_REVPREC' in codes
|
||||
assert 'OOS' not in codes and 'AOFERTA' not in codes and 'REVPREC' not in codes
|
||||
|
||||
|
||||
def test_sync_external_rail_runs_in_worker_thread():
|
||||
main=threading.current_thread().name
|
||||
class R:
|
||||
code='THREAD_TEST'
|
||||
def evaluate(self,text,ctx): return RailDecision(code=self.code,allowed=True,metadata={'thread':threading.current_thread().name})
|
||||
result=asyncio.run(ParallelRailExecutor().run('x',{},[R()]))
|
||||
assert result.results[0].metadata['thread'] != main
|
||||
|
||||
|
||||
def test_external_judges_load_from_agent_config():
|
||||
pipeline=JudgePipeline(config_path='config/judges.yaml', llm=None)
|
||||
assert [j.name for j in pipeline.judges] == ['tim_response_quality','tim_groundedness']
|
||||
|
||||
|
||||
def test_sync_judges_run_concurrently_in_threads():
|
||||
names=[]
|
||||
class J:
|
||||
def __init__(self,name): self.name=name
|
||||
def evaluate(self,q,a,c):
|
||||
names.append(threading.current_thread().name)
|
||||
return JudgeResult(name=self.name,score=1,passed=True)
|
||||
p=JudgePipeline(judges=[J('a'),J('b')], enabled=True)
|
||||
p.sample_rate=1.0
|
||||
results=asyncio.run(p.evaluate_all('q','answer with enough text',{}))
|
||||
assert [r.name for r in results]==['a','b']
|
||||
assert all(n != threading.current_thread().name for n in names)
|
||||
@@ -8,11 +8,13 @@ def test_original_default_guardrails_estao_ativos_no_pipeline_framework():
|
||||
inputs={x['code'] for x in cfg['input'] if x.get('enabled')}
|
||||
outputs={x['code'] for x in cfg['output'] if x.get('enabled')}
|
||||
assert {'MSK','INPUT_SIZE','PINJ','COER'} <= inputs
|
||||
assert {'OOS','AOFERTA','REVPREC','FRASEOLOGIA','CMP'} <= outputs
|
||||
assert {'TIM_OOS','TIM_AOFERTA','TIM_REVPREC','CMP'} <= outputs
|
||||
assert 'OOS' not in outputs and 'AOFERTA' not in outputs and 'REVPREC' not in outputs
|
||||
|
||||
|
||||
def test_framework_loader_instancia_coer_e_fraseologia():
|
||||
def test_framework_loader_instancia_coer_e_preserva_fraseologia_tim_desabilitada():
|
||||
loaded=load_guardrails_config('config/guardrails.yaml')
|
||||
codes={getattr(x,'code','') for rails in (loaded.input_rails, loaded.output_rails, loaded.retrieval_rails, loaded.tool_rails) for x in rails}
|
||||
assert 'COER' in codes
|
||||
assert 'FRASEOLOGIA' in codes
|
||||
assert 'FRASEOLOGIA' not in codes
|
||||
assert 'TIM_FRASEOLOGIA' not in codes # permanece comentado/desabilitado como no source de entrada
|
||||
|
||||
230
tests/migration/test_observability_code_mapping.py
Normal file
230
tests/migration/test_observability_code_mapping.py
Normal file
@@ -0,0 +1,230 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework.observability.code_mapper import ObservabilityCodeMapper
|
||||
from agent_framework.observability.observer import AgentObserver
|
||||
from agent_framework.observability.telemetry import Telemetry
|
||||
from app.observability.telemetry_observer import TelemetryBackedAgentObserver
|
||||
|
||||
|
||||
class CaptureAnalytics:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def publish(self, event_type, payload):
|
||||
self.calls.append((event_type, payload))
|
||||
|
||||
|
||||
class CaptureBus:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def publish(self, event_type, payload, **kwargs):
|
||||
self.calls.append((event_type, payload, kwargs))
|
||||
|
||||
|
||||
def test_mapper_passthrough_and_contract_fields(tmp_path: Path):
|
||||
mapper = ObservabilityCodeMapper({"GRL.TOXOUT": "GRL.004"})
|
||||
code, payload, metadata = mapper.normalize_payload("GRL.TOXOUT", {"x": 1}, {})
|
||||
assert code == "GRL.004"
|
||||
assert payload["event_code_internal"] == "GRL.TOXOUT"
|
||||
assert metadata["event_code_internal"] == "GRL.TOXOUT"
|
||||
assert metadata["event_code_mapped"] == "GRL.004"
|
||||
assert mapper.map("GRL.NEW") == "GRL.NEW"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_observer_maps_before_analytics_and_event_bus():
|
||||
analytics = CaptureAnalytics()
|
||||
bus = CaptureBus()
|
||||
observer = AgentObserver(
|
||||
analytics=analytics,
|
||||
event_bus=bus,
|
||||
code_mapper=ObservabilityCodeMapper({"GRL.TOXOUT": "GRL.004"}),
|
||||
)
|
||||
event = await observer.emit("GRL.TOXOUT", {"reason": "tox"})
|
||||
|
||||
assert event["eventType"] == "GRL.004"
|
||||
assert event["payload"]["tag"] == "GRL.004"
|
||||
assert event["payload"]["event_code_internal"] == "GRL.TOXOUT"
|
||||
assert analytics.calls[0][0] == "GRL.004"
|
||||
assert bus.calls[0][0] == "GRL.004"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telemetry_maps_direct_events(tmp_path: Path):
|
||||
mapping = tmp_path / "mapping.yaml"
|
||||
mapping.write_text("mappings:\n GRL.TOXOUT: GRL.004\n", encoding="utf-8")
|
||||
settings = SimpleNamespace(
|
||||
OBSERVABILITY_CODE_MAPPING_ENABLED=True,
|
||||
OBSERVABILITY_CODE_MAPPING_PATH=str(mapping),
|
||||
ENABLE_LANGFUSE=False,
|
||||
ENABLE_OCI_STREAMING=False,
|
||||
ENABLE_OTEL=False,
|
||||
LANGFUSE_TRACE_MODE="verbose",
|
||||
)
|
||||
telemetry = Telemetry(settings)
|
||||
capture = []
|
||||
|
||||
async def subscriber(evt):
|
||||
capture.append(evt)
|
||||
|
||||
telemetry.event_bus.subscribe(subscriber)
|
||||
await telemetry.event("GRL.TOXOUT", {"a": 1}, kind="grl")
|
||||
|
||||
assert capture
|
||||
assert capture[0].name == "GRL.004"
|
||||
assert capture[0].payload["event_code_internal"] == "GRL.TOXOUT"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_contas_telemetry_observer_returns_mapped_envelope(tmp_path: Path):
|
||||
mapping = tmp_path / "mapping.yaml"
|
||||
mapping.write_text("mappings:\n GRL.TOXOUT: GRL.004\n", encoding="utf-8")
|
||||
settings = SimpleNamespace(
|
||||
OBSERVABILITY_CODE_MAPPING_ENABLED=True,
|
||||
OBSERVABILITY_CODE_MAPPING_PATH=str(mapping),
|
||||
ENABLE_LANGFUSE=False,
|
||||
ENABLE_OCI_STREAMING=False,
|
||||
ENABLE_OTEL=False,
|
||||
LANGFUSE_TRACE_MODE="verbose",
|
||||
)
|
||||
telemetry = Telemetry(settings)
|
||||
observer = TelemetryBackedAgentObserver(telemetry)
|
||||
event = await observer.emit("GRL.TOXOUT", {"rail": "TOXOUT"})
|
||||
|
||||
assert event["eventType"] == "GRL.004"
|
||||
assert event["body"]["tag"] == "GRL.004"
|
||||
assert event["metadata"]["event_code_internal"] == "GRL.TOXOUT"
|
||||
|
||||
class _FakeObservation:
|
||||
def __init__(self):
|
||||
self.updates = []
|
||||
|
||||
def update(self, **kwargs):
|
||||
self.updates.append(kwargs)
|
||||
|
||||
|
||||
class _FakeObservationCM:
|
||||
def __init__(self, observation):
|
||||
self.observation = observation
|
||||
|
||||
def __enter__(self):
|
||||
return self.observation
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
class _FakeLangfuse:
|
||||
def __init__(self):
|
||||
self.started = []
|
||||
|
||||
def start_as_current_observation(self, **kwargs):
|
||||
self.started.append(dict(kwargs))
|
||||
return _FakeObservationCM(_FakeObservation())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generation_observation_name_is_mapped_before_langfuse_and_event_bus(tmp_path: Path):
|
||||
mapping = tmp_path / "mapping.yaml"
|
||||
mapping.write_text(
|
||||
"mappings:\n guardrail.dlex_in: GRL.004\n guardrail.tox: GRL.005\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
settings = SimpleNamespace(
|
||||
OBSERVABILITY_CODE_MAPPING_ENABLED=True,
|
||||
OBSERVABILITY_CODE_MAPPING_PATH=str(mapping),
|
||||
ENABLE_LANGFUSE=False,
|
||||
ENABLE_OCI_STREAMING=False,
|
||||
ENABLE_OTEL=False,
|
||||
LANGFUSE_TRACE_MODE="verbose",
|
||||
)
|
||||
telemetry = Telemetry(settings)
|
||||
fake = _FakeLangfuse()
|
||||
telemetry.langfuse = fake
|
||||
telemetry.enabled = True
|
||||
capture = []
|
||||
|
||||
async def subscriber(evt):
|
||||
capture.append(evt)
|
||||
|
||||
telemetry.event_bus.subscribe(subscriber)
|
||||
async with telemetry.generation_span(
|
||||
"guardrail.dlex_in",
|
||||
"model-x",
|
||||
"input",
|
||||
metadata={"component": "guardrail.dlex_in"},
|
||||
) as generation:
|
||||
generation.set_output("ok")
|
||||
|
||||
assert fake.started[0]["name"] == "GRL.004"
|
||||
assert fake.started[0]["metadata"]["observability_name_internal"] == "guardrail.dlex_in"
|
||||
assert fake.started[0]["metadata"]["observability_name_mapped"] == "GRL.004"
|
||||
assert capture[0].name == "GRL.004"
|
||||
assert capture[0].payload["observability_name_internal"] == "guardrail.dlex_in"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_span_name_is_mapped_before_otel_event_bus_and_langfuse(tmp_path: Path):
|
||||
mapping = tmp_path / "mapping.yaml"
|
||||
mapping.write_text("mappings:\n guardrail.tox: GRL.005\n", encoding="utf-8")
|
||||
settings = SimpleNamespace(
|
||||
OBSERVABILITY_CODE_MAPPING_ENABLED=True,
|
||||
OBSERVABILITY_CODE_MAPPING_PATH=str(mapping),
|
||||
ENABLE_LANGFUSE=False,
|
||||
ENABLE_OCI_STREAMING=False,
|
||||
ENABLE_OTEL=False,
|
||||
LANGFUSE_TRACE_MODE="verbose",
|
||||
)
|
||||
telemetry = Telemetry(settings)
|
||||
fake = _FakeLangfuse()
|
||||
telemetry.langfuse = fake
|
||||
telemetry.enabled = True
|
||||
capture = []
|
||||
|
||||
async def subscriber(evt):
|
||||
capture.append(evt)
|
||||
|
||||
telemetry.event_bus.subscribe(subscriber)
|
||||
async with telemetry.span("guardrail.tox", input={"text": "x"}):
|
||||
pass
|
||||
|
||||
assert fake.started[0]["name"] == "GRL.005"
|
||||
assert fake.started[0]["metadata"]["observability_name_internal"] == "guardrail.tox"
|
||||
assert capture[0].name == "GRL.005.started"
|
||||
assert capture[-1].name == "GRL.005.completed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_langfuse_analytics_direct_sdk_path_also_maps_observation_name(tmp_path: Path):
|
||||
from agent_framework.analytics.providers.langfuse import LangfuseAnalyticsPublisher
|
||||
|
||||
mapping = tmp_path / "mapping.yaml"
|
||||
mapping.write_text("mappings:\n guardrail.dlex_in: GRL.004\n", encoding="utf-8")
|
||||
settings = SimpleNamespace(
|
||||
OBSERVABILITY_CODE_MAPPING_ENABLED=True,
|
||||
OBSERVABILITY_CODE_MAPPING_PATH=str(mapping),
|
||||
LANGFUSE_PUBLIC_KEY="pk",
|
||||
LANGFUSE_SECRET_KEY="sk",
|
||||
LANGFUSE_HOST="http://localhost",
|
||||
)
|
||||
fake = _FakeLangfuse()
|
||||
publisher = LangfuseAnalyticsPublisher(settings=settings, langfuse=fake)
|
||||
|
||||
# Give the technical event correlation so the provider does not intentionally skip it.
|
||||
from agent_framework.observability.context import set_observability_context, clear_observability_context
|
||||
clear_observability_context()
|
||||
set_observability_context(request_id="req-123", trace_id="req-123")
|
||||
try:
|
||||
await publisher.publish("guardrail.dlex_in", {"metadata": {"request_id": "req-123"}})
|
||||
finally:
|
||||
clear_observability_context()
|
||||
|
||||
assert fake.started, "Langfuse analytics provider did not create an observation"
|
||||
assert fake.started[0]["name"] == "GRL.004"
|
||||
assert fake.started[0]["metadata"]["observability_name_internal"] == "guardrail.dlex_in"
|
||||
assert fake.started[0]["metadata"]["observability_name_mapped"] == "GRL.004"
|
||||
@@ -0,0 +1,97 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework.guardrails.base import RailDecision
|
||||
from agent_framework.guardrails.output_supervisor import OutputSupervisor
|
||||
from agent_framework.guardrails.rail_action import RailAction
|
||||
from agent_framework.observability.code_mapper import create_observability_code_mapper
|
||||
|
||||
|
||||
def _settings(**kwargs):
|
||||
defaults = dict(
|
||||
OBSERVABILITY_DEFAULT_MAPPING_ENABLED=True,
|
||||
OBSERVABILITY_DEFAULT_MAPPING_PATH=None,
|
||||
OBSERVABILITY_CODE_MAPPING_ENABLED=False,
|
||||
OBSERVABILITY_CODE_MAPPING_PATH=None,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
|
||||
def test_old_agent_without_mapping_gets_framework_default_registry():
|
||||
mapper = create_observability_code_mapper(_settings())
|
||||
assert mapper.map("guardrail.output_supervisor.started") == "GRL.001"
|
||||
assert mapper.map("guardrail.result.block") == "GRL.004"
|
||||
assert mapper.map("guardrail.output_supervisor.completed") == "GRL.009"
|
||||
assert mapper.action_for("REVPREC") == "retry"
|
||||
assert mapper.action_for("CMP") == "retry"
|
||||
assert mapper.action_for("SCO") == "retry"
|
||||
assert mapper.action_for("GND") == "retry"
|
||||
assert mapper.action_for("ATH") == "handover"
|
||||
assert mapper.map("GRL.004") == "GRL.004"
|
||||
|
||||
|
||||
def test_agent_overlay_overrides_only_declared_entries(tmp_path: Path):
|
||||
overlay = tmp_path / "observability_mapping.yaml"
|
||||
overlay.write_text(
|
||||
"""version: \"2\"\nmappings:\n guardrail.dlex_in:\n label: GRL.004\n aliases: [DLEX_IN]\n guardrail.tox:\n label: GRL.005\n aliases: [TOX]\n""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
mapper = create_observability_code_mapper(_settings(
|
||||
OBSERVABILITY_CODE_MAPPING_ENABLED=True,
|
||||
OBSERVABILITY_CODE_MAPPING_PATH=str(overlay),
|
||||
))
|
||||
# Agent-specific contract overrides framework named GRL.DLEX_IN / GRL.TOX.
|
||||
assert mapper.map("guardrail.dlex_in") == "GRL.004"
|
||||
assert mapper.map("DLEX_IN") == "GRL.004"
|
||||
assert mapper.map("TOX") == "GRL.005"
|
||||
# Unrelated framework defaults remain intact.
|
||||
assert mapper.map("guardrail.result.retry") == "GRL.005"
|
||||
assert mapper.action_for("REVPREC") == "retry"
|
||||
assert mapper.map("guardrail.pinj") == "GRL.PINJ"
|
||||
|
||||
|
||||
def test_agent_overlay_can_override_default_action(tmp_path: Path):
|
||||
overlay = tmp_path / "observability_mapping.yaml"
|
||||
overlay.write_text(
|
||||
"""mappings:\n guardrail.revprec:\n action: handover\n aliases: [REVPREC]\n""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
mapper = create_observability_code_mapper(_settings(
|
||||
OBSERVABILITY_CODE_MAPPING_ENABLED=True,
|
||||
OBSERVABILITY_CODE_MAPPING_PATH=str(overlay),
|
||||
))
|
||||
assert mapper.action_for("REVPREC") == "handover"
|
||||
# Other historical defaults are still inherited.
|
||||
assert mapper.action_for("CMP") == "retry"
|
||||
|
||||
|
||||
def test_framework_default_carries_legacy_phraseology_rewrite_policy():
|
||||
mapper = create_observability_code_mapper(_settings())
|
||||
remediation = mapper.remediation_for("FRASEOLOGIA")
|
||||
assert remediation is not None
|
||||
assert remediation["type"] == "rewrite"
|
||||
assert remediation["max_attempts"] == 1
|
||||
assert remediation["generation_name"] == "guardrail.fraseologia.rewrite"
|
||||
|
||||
|
||||
class _DeniedLegacyRail:
|
||||
code = "REVPREC"
|
||||
|
||||
async def evaluate(self, text, context):
|
||||
return RailDecision(code=self.code, allowed=False, reason="premature")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_old_legacy_rail_keeps_retry_with_new_framework_and_no_agent_mapping():
|
||||
mapper = create_observability_code_mapper(_settings())
|
||||
supervisor = OutputSupervisor(
|
||||
rails=[_DeniedLegacyRail()],
|
||||
enable_parallel=False,
|
||||
observability_mapper=mapper,
|
||||
)
|
||||
decision = await supervisor.evaluate("candidate", {})
|
||||
assert decision.action == RailAction.RETRY
|
||||
assert decision.results[0].metadata.get("action_source") == "observability_mapping"
|
||||
@@ -0,0 +1,57 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FW = ROOT / "agent_framework_oci" / "libs" / "agent_framework" / "src"
|
||||
if str(FW) not in sys.path:
|
||||
sys.path.insert(0, str(FW))
|
||||
|
||||
from agent_framework.llm.providers import MockLLMProvider
|
||||
from agent_framework.observability.code_mapper import ObservabilityCodeMapper
|
||||
|
||||
|
||||
class _CaptureGeneration:
|
||||
def set_output(self, value): pass
|
||||
def set_usage(self, value): pass
|
||||
def set_metadata(self, **value): pass
|
||||
|
||||
|
||||
class _TelemetryWithoutOwnNormalization:
|
||||
"""Capture the name exactly as received from the LLM provider."""
|
||||
def __init__(self):
|
||||
self.code_mapper = ObservabilityCodeMapper({
|
||||
"guardrail.dlex_in": "GRL.004",
|
||||
"guardrail.tox": "GRL.005",
|
||||
})
|
||||
self.names = []
|
||||
|
||||
@asynccontextmanager
|
||||
async def generation_span(self, **attrs):
|
||||
self.names.append(attrs["name"])
|
||||
yield _CaptureGeneration()
|
||||
|
||||
|
||||
async def test_provider_maps_guardrail_name_before_telemetry():
|
||||
telemetry = _TelemetryWithoutOwnNormalization()
|
||||
provider = MockLLMProvider(telemetry=telemetry)
|
||||
await provider.ainvoke(
|
||||
[{"role": "user", "content": "x"}],
|
||||
generation_name="guardrail.dlex_in",
|
||||
component_name="guardrail.dlex_in",
|
||||
)
|
||||
assert telemetry.names == ["GRL.004"]
|
||||
|
||||
|
||||
def test_mapping_path_can_resolve_from_python_import_root(tmp_path, monkeypatch):
|
||||
project = tmp_path / "agent"
|
||||
config = project / "config"
|
||||
config.mkdir(parents=True)
|
||||
mapping = config / "observability_mapping.yaml"
|
||||
mapping.write_text("mappings:\n guardrail.dlex_in: GRL.004\n", encoding="utf-8")
|
||||
elsewhere = tmp_path / "runner"
|
||||
elsewhere.mkdir()
|
||||
monkeypatch.chdir(elsewhere)
|
||||
monkeypatch.setattr(sys, "path", [str(project), *sys.path])
|
||||
mapper = ObservabilityCodeMapper.from_yaml("./config/observability_mapping.yaml")
|
||||
assert mapper.map("guardrail.dlex_in") == "GRL.004"
|
||||
@@ -0,0 +1,89 @@
|
||||
import pytest
|
||||
|
||||
from agent_framework.guardrails.base import RailDecision
|
||||
from agent_framework.guardrails.output_supervisor import OutputSupervisor
|
||||
from agent_framework.guardrails.parallel_executor import ParallelRailExecutor
|
||||
from agent_framework.guardrails.rail_action import RailAction
|
||||
from agent_framework.observability.code_mapper import ObservabilityCodeMapper
|
||||
|
||||
|
||||
def test_compact_and_rich_mapping_are_backward_compatible():
|
||||
mapper = ObservabilityCodeMapper({
|
||||
"guardrail.dlex_in": "GRL.004",
|
||||
"guardrail.tox": {"label": "GRL.005", "aliases": ["TOX"]},
|
||||
})
|
||||
assert mapper.map("guardrail.dlex_in") == "GRL.004"
|
||||
assert mapper.map("DLEX_IN") == "GRL.004"
|
||||
assert mapper.map("TOX") == "GRL.005"
|
||||
assert mapper.map("guardrail.unknown") == "guardrail.unknown"
|
||||
|
||||
|
||||
def test_action_resolution_accepts_internal_and_external_aliases():
|
||||
mapper = ObservabilityCodeMapper({
|
||||
"guardrail.revprec": {
|
||||
"action": "retry",
|
||||
"aliases": ["REVPREC", "TIM_REVPREC"],
|
||||
},
|
||||
"guardrail.handover": {
|
||||
"action": "handover",
|
||||
"aliases": ["ATH", "HUMAN"],
|
||||
},
|
||||
})
|
||||
assert mapper.action_for("REVPREC") == "retry"
|
||||
assert mapper.action_for("TIM_REVPREC") == "retry"
|
||||
assert mapper.action_for("guardrail.revprec") == "retry"
|
||||
assert mapper.action_for("ATH") == "handover"
|
||||
assert mapper.action_for("UNKNOWN") is None
|
||||
|
||||
|
||||
class _DeniedLegacyRail:
|
||||
code = "REVPREC"
|
||||
|
||||
async def evaluate(self, text, context):
|
||||
return RailDecision(code=self.code, allowed=False, reason="premature")
|
||||
|
||||
|
||||
class _DeniedPolicyRail:
|
||||
code = "REVPREC"
|
||||
_guardrail_policy = {"on_deny": "handover"}
|
||||
|
||||
async def evaluate(self, text, context):
|
||||
return RailDecision(code=self.code, allowed=False, reason="premature")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_output_supervisor_uses_registry_action_without_name_hardcode():
|
||||
mapper = ObservabilityCodeMapper({"guardrail.revprec": {"action": "retry"}})
|
||||
supervisor = OutputSupervisor(
|
||||
rails=[_DeniedLegacyRail()],
|
||||
enable_parallel=False,
|
||||
observability_mapper=mapper,
|
||||
)
|
||||
decision = await supervisor.evaluate("candidate", {})
|
||||
assert decision.action == RailAction.RETRY
|
||||
assert decision.results[0].metadata.get("action_source") == "observability_mapping"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_guardrails_yaml_policy_precedes_registry_action():
|
||||
mapper = ObservabilityCodeMapper({"guardrail.revprec": {"action": "retry"}})
|
||||
supervisor = OutputSupervisor(
|
||||
rails=[_DeniedPolicyRail()],
|
||||
enable_parallel=False,
|
||||
observability_mapper=mapper,
|
||||
)
|
||||
decision = await supervisor.evaluate("candidate", {})
|
||||
assert decision.action == RailAction.HANDOVER
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parallel_executor_uses_same_registry():
|
||||
mapper = ObservabilityCodeMapper({"guardrail.revprec": {"action": "retry"}})
|
||||
executor = ParallelRailExecutor(
|
||||
fail_fast=True,
|
||||
observability_mapper=mapper,
|
||||
)
|
||||
execution = await executor.run("candidate", {}, [_DeniedLegacyRail()])
|
||||
assert execution.terminal_result is not None
|
||||
assert execution.terminal_result.action == RailAction.RETRY
|
||||
assert execution.terminal_result.metadata.get("action_source") == "observability_mapping"
|
||||
@@ -3,8 +3,8 @@ from __future__ import annotations
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
from agent_framework.guardrails.calibrated import contestation_validation
|
||||
from agent_framework.guardrails.calibrated.contestation_validation import (
|
||||
from app.domain.contas import contestation_validation
|
||||
from app.domain.contas.contestation_validation import (
|
||||
validate_contestation_items,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from agent_framework.guardrails.base import RailDecision
|
||||
from agent_framework.guardrails.output_supervisor import OutputSupervisor
|
||||
from agent_framework.guardrails.rail_action import RailAction
|
||||
|
||||
|
||||
class RetryByMetadataRail:
|
||||
code = "ANY_POLICY_RAIL"
|
||||
async def evaluate(self, text, context):
|
||||
return RailDecision(code=self.code, allowed=False, reason="retry me", metadata={"terminal_action": "retry"})
|
||||
|
||||
|
||||
class RewriteByMetadataRail:
|
||||
code = "ANY_WORDING_RAIL"
|
||||
def __init__(self): self.calls = 0
|
||||
async def evaluate(self, text, context):
|
||||
self.calls += 1
|
||||
blocked = self.calls == 1
|
||||
return RailDecision(
|
||||
code=self.code, allowed=not blocked, reason="wording" if blocked else "",
|
||||
sanitized_text=text,
|
||||
metadata={"remediation": {"type":"rewrite", "max_attempts":1, "prompt_id":"FALLBACK"}},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_is_driven_by_metadata_not_rail_name():
|
||||
sup = OutputSupervisor(rails=[RetryByMetadataRail()], enable_parallel=False)
|
||||
decision = await sup.evaluate("x", {})
|
||||
assert decision.action == RailAction.RETRY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rewrite_is_driven_by_remediation_metadata(monkeypatch):
|
||||
rail = RewriteByMetadataRail()
|
||||
async def fake(*args, **kwargs):
|
||||
return {"reason": "rewritten"}
|
||||
monkeypatch.setattr("agent_framework.guardrails.output_supervisor.classify_with_framework_llm", fake)
|
||||
sup = OutputSupervisor(rails=[rail], enable_parallel=False, llm=object())
|
||||
decision = await sup.evaluate("original", {})
|
||||
assert decision.candidate == "rewritten"
|
||||
assert decision.metadata["guardrail_rewritten"] is True
|
||||
assert decision.metadata["guardrail_rewrite_code"] == "ANY_WORDING_RAIL"
|
||||
assert any(r.code == "ANY_WORDING_RAIL_REWRITE" for r in decision.results)
|
||||
|
||||
|
||||
def test_output_supervisor_contains_no_customer_grl_taxonomy_or_named_policy_switches():
|
||||
path = Path(__file__).parents[2] / "agent_framework_oci/libs/agent_framework/src/agent_framework/guardrails/output_supervisor.py"
|
||||
text = path.read_text(encoding="utf-8")
|
||||
assert "GRL.001" not in text
|
||||
assert "GRL.004" not in text
|
||||
assert "GRL.009" not in text
|
||||
assert 'code in {"REVPREC"' not in text
|
||||
assert '== "FRASEOLOGIA"' not in text
|
||||
Reference in New Issue
Block a user