From 5a42e063213902de0d82286f58d9430464f6f2a8 Mon Sep 17 00:00:00 2001 From: CleverThis Date: Tue, 21 Apr 2026 09:40:23 +0000 Subject: [PATCH] feat: implement 4-level automation profile precedence chain (plan > action > project > global) - Add PROJECT level to PrecedenceSource enum - Update resolve_precedence_chain() to accept project_profile parameter - Implement 4-level precedence logic: plan > action > project > global - Expand BDD scenarios from 8 to 16 combinations to cover all permutations - Update step definitions to support project-level profile resolution - Update module docstring and precedence table to reflect 4-level chain - Maintain backward compatibility with existing code patterns Closes #8234 --- ...utomation_profile_precedence_chain.feature | 148 +++++++++++++----- ...tomation_profile_precedence_chain_steps.py | 140 +++++++++++++++-- .../services/automation_profile_precedence.py | 103 ++++++++---- 3 files changed, 304 insertions(+), 87 deletions(-) diff --git a/features/automation_profile_precedence_chain.feature b/features/automation_profile_precedence_chain.feature index c4517bb04..ea6789f82 100644 --- a/features/automation_profile_precedence_chain.feature +++ b/features/automation_profile_precedence_chain.feature @@ -1,73 +1,137 @@ -Feature: Automation Profile Precedence Chain (plan > action > global) - The automation profile resolution follows a strict three-level precedence - chain: plan-level > action-level > global-level. This ensures that +Feature: Automation Profile Precedence Chain (plan > action > project > global) + The automation profile resolution follows a strict four-level precedence + chain: plan-level > action-level > project-level > global-level. This ensures that fine-grained overrides work as intended for autonomous plan execution. # ============================================================ - # All 8 combinations: plan(set/unset) × action(set/unset) × global(set/default) + # All 16 combinations: plan(set/unset) × action(set/unset) × project(set/unset) × global(set/default) # ============================================================ - # --- Combination 1: plan=set, action=set, global=set → plan wins --- + # --- Combination 1: plan=set, action=set, project=set, global=set → plan wins --- - Scenario: Combination 1 - plan overrides action and explicit global + Scenario: Combination 1 - plan overrides action, project, and explicit global Given the global profile is configured as "cautious" - When I resolve the precedence chain with plan "ci" action "auto" + When I resolve the precedence chain with plan "ci" action "auto" project "trusted" Then the resolved precedence profile name should be "ci" And the precedence source should be "plan" - # --- Combination 2: plan=set, action=set, global=default → plan wins --- + # --- Combination 2: plan=set, action=set, project=set, global=default → plan wins --- - Scenario: Combination 2 - plan overrides action when global is default + Scenario: Combination 2 - plan overrides action and project when global is default Given the global profile is the default - When I resolve the precedence chain with plan "ci" action "auto" + When I resolve the precedence chain with plan "ci" action "auto" project "trusted" Then the resolved precedence profile name should be "ci" And the precedence source should be "plan" - # --- Combination 3: plan=set, action=unset, global=set → plan wins --- + # --- Combination 3: plan=set, action=set, project=unset, global=set → plan wins --- - Scenario: Combination 3 - plan overrides explicit global when action is absent + Scenario: Combination 3 - plan overrides action and explicit global when project is absent Given the global profile is configured as "cautious" - When I resolve the precedence chain with plan "ci" and no action + When I resolve the precedence chain with plan "ci" action "auto" and no project Then the resolved precedence profile name should be "ci" And the precedence source should be "plan" - # --- Combination 4: plan=set, action=unset, global=default → plan wins --- + # --- Combination 4: plan=set, action=set, project=unset, global=default → plan wins --- - Scenario: Combination 4 - plan overrides default global when action is absent + Scenario: Combination 4 - plan overrides action when project and global are default Given the global profile is the default - When I resolve the precedence chain with plan "ci" and no action + When I resolve the precedence chain with plan "ci" action "auto" and no project Then the resolved precedence profile name should be "ci" And the precedence source should be "plan" - # --- Combination 5: plan=unset, action=set, global=set → action wins --- + # --- Combination 5: plan=set, action=unset, project=set, global=set → plan wins --- - Scenario: Combination 5 - action overrides explicit global when plan is absent + Scenario: Combination 5 - plan overrides project and explicit global when action is absent Given the global profile is configured as "cautious" - When I resolve the precedence chain with no plan and action "auto" + When I resolve the precedence chain with plan "ci" and no action project "trusted" + Then the resolved precedence profile name should be "ci" + And the precedence source should be "plan" + + # --- Combination 6: plan=set, action=unset, project=set, global=default → plan wins --- + + Scenario: Combination 6 - plan overrides project when action and global are default + Given the global profile is the default + When I resolve the precedence chain with plan "ci" and no action project "trusted" + Then the resolved precedence profile name should be "ci" + And the precedence source should be "plan" + + # --- Combination 7: plan=set, action=unset, project=unset, global=set → plan wins --- + + Scenario: Combination 7 - plan overrides explicit global when action and project are absent + Given the global profile is configured as "cautious" + When I resolve the precedence chain with plan "ci" and no action and no project + Then the resolved precedence profile name should be "ci" + And the precedence source should be "plan" + + # --- Combination 8: plan=set, action=unset, project=unset, global=default → plan wins --- + + Scenario: Combination 8 - plan overrides default global when action and project are absent + Given the global profile is the default + When I resolve the precedence chain with plan "ci" and no action and no project + Then the resolved precedence profile name should be "ci" + And the precedence source should be "plan" + + # --- Combination 9: plan=unset, action=set, project=set, global=set → action wins --- + + Scenario: Combination 9 - action overrides project and explicit global when plan is absent + Given the global profile is configured as "cautious" + When I resolve the precedence chain with no plan and action "auto" project "trusted" Then the resolved precedence profile name should be "auto" And the precedence source should be "action" - # --- Combination 6: plan=unset, action=set, global=default → action wins --- + # --- Combination 10: plan=unset, action=set, project=set, global=default → action wins --- - Scenario: Combination 6 - action overrides default global when plan is absent + Scenario: Combination 10 - action overrides project when plan and global are default Given the global profile is the default - When I resolve the precedence chain with no plan and action "auto" + When I resolve the precedence chain with no plan and action "auto" project "trusted" Then the resolved precedence profile name should be "auto" And the precedence source should be "action" - # --- Combination 7: plan=unset, action=unset, global=set → global wins --- + # --- Combination 11: plan=unset, action=set, project=unset, global=set → action wins --- - Scenario: Combination 7 - explicit global used when plan and action are absent + Scenario: Combination 11 - action overrides explicit global when plan and project are absent Given the global profile is configured as "cautious" - When I resolve the precedence chain with no plan and no action + When I resolve the precedence chain with no plan and action "auto" and no project + Then the resolved precedence profile name should be "auto" + And the precedence source should be "action" + + # --- Combination 12: plan=unset, action=set, project=unset, global=default → action wins --- + + Scenario: Combination 12 - action overrides default global when plan and project are absent + Given the global profile is the default + When I resolve the precedence chain with no plan and action "auto" and no project + Then the resolved precedence profile name should be "auto" + And the precedence source should be "action" + + # --- Combination 13: plan=unset, action=unset, project=set, global=set → project wins --- + + Scenario: Combination 13 - project overrides explicit global when plan and action are absent + Given the global profile is configured as "cautious" + When I resolve the precedence chain with no plan and no action project "trusted" + Then the resolved precedence profile name should be "trusted" + And the precedence source should be "project" + + # --- Combination 14: plan=unset, action=unset, project=set, global=default → project wins --- + + Scenario: Combination 14 - project overrides default global when plan and action are absent + Given the global profile is the default + When I resolve the precedence chain with no plan and no action project "trusted" + Then the resolved precedence profile name should be "trusted" + And the precedence source should be "project" + + # --- Combination 15: plan=unset, action=unset, project=unset, global=set → global wins --- + + Scenario: Combination 15 - explicit global used when plan, action, and project are absent + Given the global profile is configured as "cautious" + When I resolve the precedence chain with no plan and no action and no project Then the resolved precedence profile name should be "cautious" And the precedence source should be "global" - # --- Combination 8: plan=unset, action=unset, global=default → global default --- + # --- Combination 16: plan=unset, action=unset, project=unset, global=default → global default --- - Scenario: Combination 8 - default global (manual) used when nothing is set + Scenario: Combination 16 - default global (manual) used when nothing is set Given the global profile is the default - When I resolve the precedence chain with no plan and no action + When I resolve the precedence chain with no plan and no action and no project Then the resolved precedence profile name should be "manual" And the precedence source should be "global" @@ -78,7 +142,7 @@ Feature: Automation Profile Precedence Chain (plan > action > global) Scenario: Resolution chain is logged at debug level Given the global profile is the default And debug logging is captured for the precedence module - When I resolve the precedence chain with plan "ci" action "auto" + When I resolve the precedence chain with plan "ci" action "auto" project "trusted" Then a debug log entry should mention the resolved profile name "ci" And the debug log entry should mention the source "plan" @@ -88,18 +152,19 @@ Feature: Automation Profile Precedence Chain (plan > action > global) Scenario: Resolution result exposes plan profile field Given the global profile is the default - When I resolve the precedence chain with plan "ci" action "auto" + When I resolve the precedence chain with plan "ci" action "auto" project "trusted" Then the resolution plan_profile field should be "ci" And the resolution action_profile field should be "auto" + And the resolution project_profile field should be "trusted" Scenario: Resolution result exposes global profile field Given the global profile is configured as "cautious" - When I resolve the precedence chain with no plan and no action + When I resolve the precedence chain with no plan and no action and no project Then the resolution global_profile field should be "cautious" Scenario: Resolution result exposes the full AutomationProfile object Given the global profile is the default - When I resolve the precedence chain with plan "manual" and no action + When I resolve the precedence chain with plan "manual" and no action and no project Then the resolved precedence profile object should have name "manual" And the resolved precedence profile decompose_task should be 1.0 @@ -110,14 +175,14 @@ Feature: Automation Profile Precedence Chain (plan > action > global) Scenario: Env var sets global profile when no explicit global is configured Given the global profile is the default And the env var CLEVERAGENTS_AUTOMATION_PROFILE is "trusted" - When I resolve the precedence chain with no plan and no action + When I resolve the precedence chain with no plan and no action and no project Then the resolved precedence profile name should be "trusted" And the precedence source should be "global" Scenario: Plan-level still overrides env var global Given the global profile is the default And the env var CLEVERAGENTS_AUTOMATION_PROFILE is "trusted" - When I resolve the precedence chain with plan "ci" and no action + When I resolve the precedence chain with plan "ci" and no action and no project Then the resolved precedence profile name should be "ci" And the precedence source should be "plan" @@ -128,7 +193,7 @@ Feature: Automation Profile Precedence Chain (plan > action > global) Scenario: Custom profile registry is used for resolution Given the global profile is the default And a custom profile registry contains "acme/deploy" with select_tool 0.5 - When I resolve the precedence chain with plan "acme/deploy" and no action + When I resolve the precedence chain with plan "acme/deploy" and no action and no project Then the resolved precedence profile name should be "acme/deploy" And the precedence source should be "plan" @@ -138,13 +203,19 @@ Feature: Automation Profile Precedence Chain (plan > action > global) Scenario: Empty string plan profile is treated as absent Given the global profile is the default - When I resolve the precedence chain with empty plan and action "auto" + When I resolve the precedence chain with empty plan and action "auto" and no project Then the resolved precedence profile name should be "auto" And the precedence source should be "action" Scenario: Empty string action profile is treated as absent Given the global profile is the default - When I resolve the precedence chain with plan absent and empty action + When I resolve the precedence chain with plan absent and empty action and no project + Then the resolved precedence profile name should be "manual" + And the precedence source should be "global" + + Scenario: Empty string project profile is treated as absent + Given the global profile is the default + When I resolve the precedence chain with plan absent and action absent and empty project Then the resolved precedence profile name should be "manual" And the precedence source should be "global" @@ -158,5 +229,8 @@ Feature: Automation Profile Precedence Chain (plan > action > global) Scenario: PrecedenceSource ACTION has value "action" Then the PrecedenceSource ACTION value should be "action" + Scenario: PrecedenceSource PROJECT has value "project" + Then the PrecedenceSource PROJECT value should be "project" + Scenario: PrecedenceSource GLOBAL has value "global" Then the PrecedenceSource GLOBAL value should be "global" diff --git a/features/steps/automation_profile_precedence_chain_steps.py b/features/steps/automation_profile_precedence_chain_steps.py index 0ae48f3de..253167817 100644 --- a/features/steps/automation_profile_precedence_chain_steps.py +++ b/features/steps/automation_profile_precedence_chain_steps.py @@ -1,8 +1,8 @@ """Step definitions for automation profile precedence chain tests. -Tests the three-level precedence chain: plan > action > global. -Covers all 8 combinations of (plan set/unset) x (action set/unset) x -(global set/default). +Tests the four-level precedence chain: plan > action > project > global. +Covers all 16 combinations of (plan set/unset) x (action set/unset) x +(project set/unset) x (global set/default). """ from __future__ import annotations @@ -108,11 +108,11 @@ def step_given_custom_registry(context: Context, name: str, threshold: float) -> # --------------------------------------------------------------------------- -@when('I resolve the precedence chain with plan "{plan_name}" action "{action_name}"') -def step_when_resolve_plan_and_action( - context: Context, plan_name: str, action_name: str +@when('I resolve the precedence chain with plan "{plan_name}" action "{action_name}" project "{project_name}"') +def step_when_resolve_plan_action_project( + context: Context, plan_name: str, action_name: str, project_name: str ) -> None: - """Resolve with both plan and action set.""" + """Resolve with plan, action, and project all set.""" global_override: str | None = getattr(context, "global_override", None) registry: dict[str, AutomationProfile] | None = getattr( context, "custom_registry", None @@ -120,14 +120,51 @@ def step_when_resolve_plan_and_action( context.resolution: PrecedenceResolution = resolve_precedence_chain( plan_profile=plan_name, action_profile=action_name, + project_profile=project_name, global_override=global_override, profile_registry=registry, ) -@when('I resolve the precedence chain with plan "{plan_name}" and no action') +@when('I resolve the precedence chain with plan "{plan_name}" action "{action_name}" and no project') +def step_when_resolve_plan_and_action( + context: Context, plan_name: str, action_name: str +) -> None: + """Resolve with plan and action set, project absent.""" + global_override: str | None = getattr(context, "global_override", None) + registry: dict[str, AutomationProfile] | None = getattr( + context, "custom_registry", None + ) + context.resolution: PrecedenceResolution = resolve_precedence_chain( + plan_profile=plan_name, + action_profile=action_name, + project_profile=None, + global_override=global_override, + profile_registry=registry, + ) + + +@when('I resolve the precedence chain with plan "{plan_name}" and no action project "{project_name}"') +def step_when_resolve_plan_and_project( + context: Context, plan_name: str, project_name: str +) -> None: + """Resolve with plan and project set, action absent.""" + global_override: str | None = getattr(context, "global_override", None) + registry: dict[str, AutomationProfile] | None = getattr( + context, "custom_registry", None + ) + context.resolution: PrecedenceResolution = resolve_precedence_chain( + plan_profile=plan_name, + action_profile=None, + project_profile=project_name, + global_override=global_override, + profile_registry=registry, + ) + + +@when('I resolve the precedence chain with plan "{plan_name}" and no action and no project') def step_when_resolve_plan_only(context: Context, plan_name: str) -> None: - """Resolve with plan set, action absent.""" + """Resolve with plan set, action and project absent.""" global_override: str | None = getattr(context, "global_override", None) registry: dict[str, AutomationProfile] | None = getattr( context, "custom_registry", None @@ -135,14 +172,33 @@ def step_when_resolve_plan_only(context: Context, plan_name: str) -> None: context.resolution = resolve_precedence_chain( plan_profile=plan_name, action_profile=None, + project_profile=None, global_override=global_override, profile_registry=registry, ) -@when('I resolve the precedence chain with no plan and action "{action_name}"') +@when('I resolve the precedence chain with no plan and action "{action_name}" project "{project_name}"') +def step_when_resolve_action_and_project( + context: Context, action_name: str, project_name: str +) -> None: + """Resolve with action and project set, plan absent.""" + global_override: str | None = getattr(context, "global_override", None) + registry: dict[str, AutomationProfile] | None = getattr( + context, "custom_registry", None + ) + context.resolution: PrecedenceResolution = resolve_precedence_chain( + plan_profile=None, + action_profile=action_name, + project_profile=project_name, + global_override=global_override, + profile_registry=registry, + ) + + +@when('I resolve the precedence chain with no plan and action "{action_name}" and no project') def step_when_resolve_action_only(context: Context, action_name: str) -> None: - """Resolve with action set, plan absent.""" + """Resolve with action set, plan and project absent.""" global_override: str | None = getattr(context, "global_override", None) registry: dict[str, AutomationProfile] | None = getattr( context, "custom_registry", None @@ -150,14 +206,15 @@ def step_when_resolve_action_only(context: Context, action_name: str) -> None: context.resolution = resolve_precedence_chain( plan_profile=None, action_profile=action_name, + project_profile=None, global_override=global_override, profile_registry=registry, ) -@when("I resolve the precedence chain with no plan and no action") -def step_when_resolve_global_only(context: Context) -> None: - """Resolve with neither plan nor action set.""" +@when('I resolve the precedence chain with no plan and no action project "{project_name}"') +def step_when_resolve_project_only(context: Context, project_name: str) -> None: + """Resolve with project set, plan and action absent.""" global_override: str | None = getattr(context, "global_override", None) registry: dict[str, AutomationProfile] | None = getattr( context, "custom_registry", None @@ -165,12 +222,29 @@ def step_when_resolve_global_only(context: Context) -> None: context.resolution = resolve_precedence_chain( plan_profile=None, action_profile=None, + project_profile=project_name, global_override=global_override, profile_registry=registry, ) -@when('I resolve the precedence chain with empty plan and action "{action_name}"') +@when("I resolve the precedence chain with no plan and no action and no project") +def step_when_resolve_global_only(context: Context) -> None: + """Resolve with plan, action, and project all absent.""" + global_override: str | None = getattr(context, "global_override", None) + registry: dict[str, AutomationProfile] | None = getattr( + context, "custom_registry", None + ) + context.resolution = resolve_precedence_chain( + plan_profile=None, + action_profile=None, + project_profile=None, + global_override=global_override, + profile_registry=registry, + ) + + +@when('I resolve the precedence chain with empty plan and action "{action_name}" and no project') def step_when_resolve_empty_plan_with_action( context: Context, action_name: str ) -> None: @@ -179,17 +253,31 @@ def step_when_resolve_empty_plan_with_action( context.resolution = resolve_precedence_chain( plan_profile="", action_profile=action_name, + project_profile=None, global_override=global_override, ) -@when("I resolve the precedence chain with plan absent and empty action") +@when("I resolve the precedence chain with plan absent and empty action and no project") def step_when_resolve_absent_plan_empty_action(context: Context) -> None: """Resolve with plan absent and empty string action (treated as absent).""" global_override: str | None = getattr(context, "global_override", None) context.resolution = resolve_precedence_chain( plan_profile=None, action_profile="", + project_profile=None, + global_override=global_override, + ) + + +@when("I resolve the precedence chain with plan absent and action absent and empty project") +def step_when_resolve_absent_plan_action_empty_project(context: Context) -> None: + """Resolve with plan and action absent and empty string project (treated as absent).""" + global_override: str | None = getattr(context, "global_override", None) + context.resolution = resolve_precedence_chain( + plan_profile=None, + action_profile=None, + project_profile="", global_override=global_override, ) @@ -210,7 +298,7 @@ def step_then_resolved_name(context: Context, expected: str) -> None: @then('the precedence source should be "{expected_source}"') def step_then_precedence_source(context: Context, expected_source: str) -> None: - """Assert the precedence source (plan, action, or global).""" + """Assert the precedence source (plan, action, project, or global).""" actual = context.resolution.source.value assert actual == expected_source, ( f"Expected precedence source '{expected_source}', got '{actual}'" @@ -235,6 +323,15 @@ def step_then_resolution_action_field(context: Context, expected: str) -> None: ) +@then('the resolution project_profile field should be "{expected}"') +def step_then_resolution_project_field(context: Context, expected: str) -> None: + """Assert the project_profile field on the resolution result.""" + actual = context.resolution.project_profile + assert actual == expected, ( + f"Expected resolution.project_profile '{expected}', got '{actual}'" + ) + + @then('the resolution global_profile field should be "{expected}"') def step_then_resolution_global_field(context: Context, expected: str) -> None: """Assert the global_profile field on the resolution result.""" @@ -310,6 +407,15 @@ def step_then_precedence_source_action_value(context: Context, expected: str) -> ) +@then('the PrecedenceSource PROJECT value should be "{expected}"') +def step_then_precedence_source_project_value(context: Context, expected: str) -> None: + """Assert PrecedenceSource.PROJECT has the expected string value.""" + assert PrecedenceSource.PROJECT.value == expected, ( + f"Expected PrecedenceSource.PROJECT.value '{expected}', " + f"got '{PrecedenceSource.PROJECT.value}'" + ) + + @then('the PrecedenceSource GLOBAL value should be "{expected}"') def step_then_precedence_source_global_value(context: Context, expected: str) -> None: """Assert PrecedenceSource.GLOBAL has the expected string value.""" diff --git a/src/cleveragents/application/services/automation_profile_precedence.py b/src/cleveragents/application/services/automation_profile_precedence.py index 55f5f274a..2325de553 100644 --- a/src/cleveragents/application/services/automation_profile_precedence.py +++ b/src/cleveragents/application/services/automation_profile_precedence.py @@ -1,9 +1,9 @@ """Automation Profile Precedence Chain for CleverAgents v3.5. -Implements and validates the three-level precedence chain for automation +Implements and validates the four-level precedence chain for automation profile resolution: - plan > action > global + plan > action > project > global This module provides a dedicated, testable implementation of the precedence chain that is independent of the full ``AutomationProfileService`` CRUD @@ -14,38 +14,56 @@ Precedence Rules ---------------- 1. **Plan-level** — explicitly set via ``--automation-profile`` on plan use. - Highest priority; overrides action and global. + Highest priority; overrides action, project, and global. 2. **Action-level** — default profile set on the action template. - Overrides global; used when no plan-level override is set. -3. **Global-level** — set via config key or + Overrides project and global; used when no plan-level override is set. +3. **Project-level** — default profile set at the project level. + Overrides global; used when neither plan nor action override is set. +4. **Global-level** — set via config key or ``CLEVERAGENTS_AUTOMATION_PROFILE`` env var, falling back to ``"manual"``. - Used when neither plan nor action override is set. + Used when none of plan, action, or project override is set. -All 8 Combinations ------------------- +All 16 Combinations +------------------- -The precedence chain covers all 8 combinations of -(plan set / not set) x (action set / not set) x (global set / default): +The precedence chain covers all 16 combinations of: +(plan set/unset) x (action set/unset) x (project set/unset) x (global set/default) -+-------+--------+--------+------------------+ -| plan | action | global | resolved source | -+=======+========+========+==================+ -| set | set | set | plan | -+-------+--------+--------+------------------+ -| set | set | default| plan | -+-------+--------+--------+------------------+ -| set | unset | set | plan | -+-------+--------+--------+------------------+ -| set | unset | default| plan | -+-------+--------+--------+------------------+ -| unset | set | set | action | -+-------+--------+--------+------------------+ -| unset | set | default| action | -+-------+--------+--------+------------------+ -| unset | unset | set | global | -+-------+--------+--------+------------------+ -| unset | unset | default| global (manual) | -+-------+--------+--------+------------------+ ++-------+--------+---------+--------+------------------+ +| plan | action | project | global | resolved source | ++=======+========+=========+========+==================+ +| set | set | set | set | plan | ++-------+--------+---------+--------+------------------+ +| set | set | set | default| plan | ++-------+--------+---------+--------+------------------+ +| set | set | unset | set | plan | ++-------+--------+---------+--------+------------------+ +| set | set | unset | default| plan | ++-------+--------+---------+--------+------------------+ +| set | unset | set | set | plan | ++-------+--------+---------+--------+------------------+ +| set | unset | set | default| plan | ++-------+--------+---------+--------+------------------+ +| set | unset | unset | set | plan | ++-------+--------+---------+--------+------------------+ +| set | unset | unset | default| plan | ++-------+--------+---------+--------+------------------+ +| unset | set | set | set | action | ++-------+--------+---------+--------+------------------+ +| unset | set | set | default| action | ++-------+--------+---------+--------+------------------+ +| unset | set | unset | set | action | ++-------+--------+---------+--------+------------------+ +| unset | set | unset | default| action | ++-------+--------+---------+--------+------------------+ +| unset | unset | set | set | project | ++-------+--------+---------+--------+------------------+ +| unset | unset | set | default| project | ++-------+--------+---------+--------+------------------+ +| unset | unset | unset | set | global | ++-------+--------+---------+--------+------------------+ +| unset | unset | unset | default| global (manual) | ++-------+--------+---------+--------+------------------+ Based on ``docs/specification.md`` Section "Automation Profiles". """ @@ -71,11 +89,13 @@ _DEFAULT_GLOBAL_PROFILE = "manual" class PrecedenceSource(StrEnum): """Indicates which level of the precedence chain resolved the profile. - Values correspond to the three levels of the plan > action > global chain. + Values correspond to the four levels of the plan > action > project > global + chain. """ PLAN = "plan" ACTION = "action" + PROJECT = "project" GLOBAL = "global" @@ -89,6 +109,7 @@ class PrecedenceResolution: profile_name: The resolved profile name string. plan_profile: The plan-level override (may be ``None``). action_profile: The action-level override (may be ``None``). + project_profile: The project-level override (may be ``None``). global_profile: The effective global profile name used as fallback. """ @@ -97,6 +118,7 @@ class PrecedenceResolution: profile_name: str plan_profile: str | None action_profile: str | None + project_profile: str | None global_profile: str @@ -129,12 +151,13 @@ def resolve_precedence_chain( *, plan_profile: str | None = None, action_profile: str | None = None, + project_profile: str | None = None, global_override: str | None = None, profile_registry: dict[str, AutomationProfile] | None = None, ) -> PrecedenceResolution: """Resolve the effective automation profile using the precedence chain. - Implements the canonical plan > action > global precedence chain. + Implements the canonical plan > action > project > global precedence chain. The resolution is logged at ``DEBUG`` level so callers can trace which level provided the profile. @@ -143,6 +166,8 @@ def resolve_precedence_chain( ``None`` or empty string means no plan-level override. action_profile: Profile name set on the action template. ``None`` or empty string means no action-level override. + project_profile: Profile name set at the project level. + ``None`` or empty string means no project-level override. global_override: Explicit global profile name from config service. Falls back to ``CLEVERAGENTS_AUTOMATION_PROFILE`` env var, then ``"manual"``. @@ -169,6 +194,10 @@ def resolve_precedence_chain( >>> result.source + >>> result = resolve_precedence_chain(project_profile="trusted") + >>> result.source + + >>> result = resolve_precedence_chain() >>> result.source @@ -186,26 +215,33 @@ def resolve_precedence_chain( norm_action = action_profile.strip() if isinstance(action_profile, str) else None norm_action = norm_action if norm_action else None + norm_project = project_profile.strip() if isinstance(project_profile, str) else None + norm_project = norm_project if norm_project else None + effective_global = _resolve_global_profile(global_override) - # Apply precedence chain: plan > action > global + # Apply precedence chain: plan > action > project > global if norm_plan is not None: chosen_name = norm_plan source = PrecedenceSource.PLAN elif norm_action is not None: chosen_name = norm_action source = PrecedenceSource.ACTION + elif norm_project is not None: + chosen_name = norm_project + source = PrecedenceSource.PROJECT else: chosen_name = effective_global source = PrecedenceSource.GLOBAL logger.debug( "Automation profile precedence chain resolved: " - "name=%r source=%s plan=%r action=%r global=%r", + "name=%r source=%s plan=%r action=%r project=%r global=%r", chosen_name, source.value, norm_plan, norm_action, + norm_project, effective_global, ) @@ -217,5 +253,6 @@ def resolve_precedence_chain( profile_name=chosen_name, plan_profile=norm_plan, action_profile=norm_action, + project_profile=norm_project, global_profile=effective_global, )