address comments

This commit is contained in:
Lorenze Jay
2026-06-06 20:59:46 -07:00
parent 7edbddc40d
commit b92df2f213
5 changed files with 104 additions and 13 deletions

View File

@@ -354,7 +354,7 @@ Routes:
### أذونات المسارات
استخدم `@listen(..., required_permissions=[...])` لحماية المسارات بعد أن يختار الموجّه مساراً صالحاً. يتحقق المفوّض الافتراضي من مجموعة `permissions` في `self.state`؛ وتُعاد الجولات المرفوضة إلى `permission_denied`.
استخدم `@listen(..., required_permissions=[...])` لحماية المسارات بعد أن يختار الموجّه مسارًا صالحًا. يتحقق المفوّض الافتراضي من مجموعة `permissions` في `self.state`؛ وتُعاد الجولات المرفوضة إلى `permission_denied`.
```python
from crewai import Flow
@@ -402,7 +402,7 @@ class SupportFlow(Flow[AppState]):
return reply
```
استخدم `RouterConfig.route_permissions` عندما تحتاج إلى تكوين الأذونات بعيداً عن المعالجات. للتفويض المخصص، تجاوز `can_access_route(required_permissions)` بدلاً من تجاوز `route_turn`.
استخدم `RouterConfig.route_permissions` عندما تحتاج إلى تكوين الأذونات بعيدًا عن المعالجات. للتفويض المخصص، تجاوز `can_access_route(required_permissions)` بدلًا من تجاوز `route_turn`.
### المسارات المدمجة

View File

@@ -659,7 +659,10 @@ class _ConversationalMixin:
) -> str | None:
router_llm = self._default_router_llm(router_config)
if router_llm is None:
return router_config.default_intent
return self._route_or_permission_denied(
router_config.default_intent,
router_config,
)
try:
llm = self._coerce_llm(router_llm)
@@ -670,19 +673,36 @@ class _ConversationalMixin:
)
intent = self._extract_router_intent(response, router_config.intent_field)
except Exception:
return router_config.fallback_intent or router_config.default_intent
return self._route_or_permission_denied(
router_config.fallback_intent or router_config.default_intent,
router_config,
)
if intent is None:
return router_config.fallback_intent or router_config.default_intent
return self._route_or_permission_denied(
router_config.fallback_intent or router_config.default_intent,
router_config,
)
valid_labels = self._effective_routes(router_config)
if valid_labels and intent not in valid_labels:
return router_config.fallback_intent or router_config.default_intent
return self._route_or_permission_denied(
router_config.fallback_intent or router_config.default_intent,
router_config,
)
if not self._can_access_router_intent(intent, router_config):
return self._route_or_permission_denied(intent, router_config)
def _route_or_permission_denied(
self,
route: str | None,
router_config: RouterConfig,
) -> str | None:
if route is None:
return None
if not self._can_access_router_intent(route, router_config):
return router_config.permission_denied_route
return intent
return route
def _can_access_router_intent(
self,

View File

@@ -64,6 +64,11 @@ def listen(
)
if not permissions:
raise ValueError("required_permissions must not be empty")
if any(
not isinstance(permission, str) or not permission.strip()
for permission in permissions
):
raise ValueError("required_permissions must contain non-empty strings")
wrapper.__route_permissions__ = permissions
return wrapper

View File

@@ -171,6 +171,7 @@ class ListenMethod(FlowMethod[P, R]):
__trigger_methods__: list[FlowMethodName] | None = None
__condition_type__: FlowConditionType | None = None
__trigger_condition__: FlowCondition | None = None
__route_permissions__: tuple[str, ...] | None = None
class RouterMethod(FlowMethod[P, R]):

View File

@@ -891,6 +891,7 @@ class TestConversationalFlow:
return "denied"
flow = PermissionFlow()
flow._state = PermissionState()
result = flow.handle_turn("research CrewAI")
assert result == "denied"
@@ -929,6 +930,7 @@ class TestConversationalFlow:
return "denied"
flow = PermissionFlow()
flow._state = PermissionState()
result = flow.handle_turn("research CrewAI")
assert result == "researched"
@@ -970,10 +972,65 @@ class TestConversationalFlow:
assert result == "admin report"
assert flow.state.last_intent == "admin"
def test_router_infers_permissions_from_listener_metadata(self) -> None:
class PermissionState(ConversationState):
permissions: set[str] = Field(default_factory=set)
def test_router_permissions_gate_protected_default_intent(self) -> None:
@ConversationConfig(
router=RouterConfig(
routes=["admin"],
route_permissions={"admin": "admin"},
default_intent="admin",
),
)
class PermissionFlow(ConversationalFlow):
@listen("admin")
def run_admin(self) -> str:
self.append_assistant_message("admin report")
return "admin report"
@listen("permission_denied")
def deny(self) -> str:
self.append_assistant_message("denied")
return "denied"
flow = PermissionFlow()
result = flow.handle_turn("show audit")
assert result == "denied"
assert flow.state.last_intent == "permission_denied"
def test_router_permissions_gate_protected_fallback_intent(self) -> None:
class Route(BaseModel):
intent: str
router_llm = MagicMock()
router_llm.call.return_value = Route(intent="unknown")
@ConversationConfig(
router=RouterConfig(
response_format=Route,
llm=router_llm,
routes=["admin"],
route_permissions={"admin": "admin"},
fallback_intent="admin",
),
)
class PermissionFlow(ConversationalFlow):
@listen("admin")
def run_admin(self) -> str:
self.append_assistant_message("admin report")
return "admin report"
@listen("permission_denied")
def deny(self) -> str:
self.append_assistant_message("denied")
return "denied"
flow = PermissionFlow()
result = flow.handle_turn("something unexpected")
assert result == "denied"
assert flow.state.last_intent == "permission_denied"
def test_router_infers_permissions_from_listener_metadata(self) -> None:
router_llm = MagicMock()
@ConversationConfig(
@@ -981,7 +1038,7 @@ class TestConversationalFlow:
llm=router_llm,
),
)
class PermissionFlow(Flow[PermissionState]):
class PermissionFlow(Flow[ConversationState]):
conversational = True
@listen("research", required_permissions=["web_search"])
@@ -1007,6 +1064,14 @@ class TestConversationalFlow:
assert result == "denied"
assert flow.state.last_intent == "permission_denied"
def test_listener_required_permissions_reject_empty_values(self) -> None:
try:
listen("research", required_permissions=["web_search", ""])(lambda: None)
except ValueError as exc:
assert "non-empty strings" in str(exc)
else:
raise AssertionError("empty permission names should be rejected")
def test_conversational_flow_auto_defaults_to_conversation_state(self) -> None:
"""``class C(Flow): conversational = True`` resolves state to ConversationState.