feat: implement 4-level automation profile precedence chain (plan > action > project > global)
CI / push-validation (pull_request) Successful in 24s
CI / helm (pull_request) Successful in 31s
CI / lint (pull_request) Failing after 57s
CI / build (pull_request) Successful in 3m47s
CI / quality (pull_request) Successful in 4m18s
CI / typecheck (pull_request) Successful in 4m35s
CI / security (pull_request) Successful in 4m55s
CI / coverage (pull_request) Has been skipped
CI / unit_tests (pull_request) Failing after 6m5s
CI / docker (pull_request) Has been skipped
CI / e2e_tests (pull_request) Successful in 7m3s
CI / integration_tests (pull_request) Successful in 8m46s
CI / status-check (pull_request) Failing after 3s
CI / benchmark-regression (pull_request) Has been cancelled
CI / benchmark-publish (pull_request) Has been cancelled

- 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
This commit is contained in:
2026-04-21 09:40:23 +00:00
parent fce9f82612
commit 5a42e06321
3 changed files with 304 additions and 87 deletions
@@ -1,73 +1,137 @@
Feature: Automation Profile Precedence Chain (plan > action > global) Feature: Automation Profile Precedence Chain (plan > action > project > global)
The automation profile resolution follows a strict three-level precedence The automation profile resolution follows a strict four-level precedence
chain: plan-level > action-level > global-level. This ensures that chain: plan-level > action-level > project-level > global-level. This ensures that
fine-grained overrides work as intended for autonomous plan execution. 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" 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" Then the resolved precedence profile name should be "ci"
And the precedence source should be "plan" 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 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" Then the resolved precedence profile name should be "ci"
And the precedence source should be "plan" 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" 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" Then the resolved precedence profile name should be "ci"
And the precedence source should be "plan" 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 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" Then the resolved precedence profile name should be "ci"
And the precedence source should be "plan" 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" 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" Then the resolved precedence profile name should be "auto"
And the precedence source should be "action" 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 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" Then the resolved precedence profile name should be "auto"
And the precedence source should be "action" 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" 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" Then the resolved precedence profile name should be "cautious"
And the precedence source should be "global" 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 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" Then the resolved precedence profile name should be "manual"
And the precedence source should be "global" 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 Scenario: Resolution chain is logged at debug level
Given the global profile is the default Given the global profile is the default
And debug logging is captured for the precedence module 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" Then a debug log entry should mention the resolved profile name "ci"
And the debug log entry should mention the source "plan" 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 Scenario: Resolution result exposes plan profile field
Given the global profile is the 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 resolution plan_profile field should be "ci" Then the resolution plan_profile field should be "ci"
And the resolution action_profile field should be "auto" 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 Scenario: Resolution result exposes global profile field
Given the global profile is configured as "cautious" 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" Then the resolution global_profile field should be "cautious"
Scenario: Resolution result exposes the full AutomationProfile object Scenario: Resolution result exposes the full AutomationProfile object
Given the global profile is the default 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" Then the resolved precedence profile object should have name "manual"
And the resolved precedence profile decompose_task should be 1.0 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 Scenario: Env var sets global profile when no explicit global is configured
Given the global profile is the default Given the global profile is the default
And the env var CLEVERAGENTS_AUTOMATION_PROFILE is "trusted" 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" Then the resolved precedence profile name should be "trusted"
And the precedence source should be "global" And the precedence source should be "global"
Scenario: Plan-level still overrides env var global Scenario: Plan-level still overrides env var global
Given the global profile is the default Given the global profile is the default
And the env var CLEVERAGENTS_AUTOMATION_PROFILE is "trusted" 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" Then the resolved precedence profile name should be "ci"
And the precedence source should be "plan" 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 Scenario: Custom profile registry is used for resolution
Given the global profile is the default Given the global profile is the default
And a custom profile registry contains "acme/deploy" with select_tool 0.5 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" Then the resolved precedence profile name should be "acme/deploy"
And the precedence source should be "plan" 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 Scenario: Empty string plan profile is treated as absent
Given the global profile is the default 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" Then the resolved precedence profile name should be "auto"
And the precedence source should be "action" And the precedence source should be "action"
Scenario: Empty string action profile is treated as absent Scenario: Empty string action profile is treated as absent
Given the global profile is the default 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" Then the resolved precedence profile name should be "manual"
And the precedence source should be "global" 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" Scenario: PrecedenceSource ACTION has value "action"
Then the PrecedenceSource ACTION value should be "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" Scenario: PrecedenceSource GLOBAL has value "global"
Then the PrecedenceSource GLOBAL value should be "global" Then the PrecedenceSource GLOBAL value should be "global"
@@ -1,8 +1,8 @@
"""Step definitions for automation profile precedence chain tests. """Step definitions for automation profile precedence chain tests.
Tests the three-level precedence chain: plan > action > global. Tests the four-level precedence chain: plan > action > project > global.
Covers all 8 combinations of (plan set/unset) x (action set/unset) x Covers all 16 combinations of (plan set/unset) x (action set/unset) x
(global set/default). (project set/unset) x (global set/default).
""" """
from __future__ import annotations 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}"') @when('I resolve the precedence chain with plan "{plan_name}" action "{action_name}" project "{project_name}"')
def step_when_resolve_plan_and_action( def step_when_resolve_plan_action_project(
context: Context, plan_name: str, action_name: str context: Context, plan_name: str, action_name: str, project_name: str
) -> None: ) -> 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) global_override: str | None = getattr(context, "global_override", None)
registry: dict[str, AutomationProfile] | None = getattr( registry: dict[str, AutomationProfile] | None = getattr(
context, "custom_registry", None context, "custom_registry", None
@@ -120,14 +120,51 @@ def step_when_resolve_plan_and_action(
context.resolution: PrecedenceResolution = resolve_precedence_chain( context.resolution: PrecedenceResolution = resolve_precedence_chain(
plan_profile=plan_name, plan_profile=plan_name,
action_profile=action_name, action_profile=action_name,
project_profile=project_name,
global_override=global_override, global_override=global_override,
profile_registry=registry, 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: 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) global_override: str | None = getattr(context, "global_override", None)
registry: dict[str, AutomationProfile] | None = getattr( registry: dict[str, AutomationProfile] | None = getattr(
context, "custom_registry", None 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( context.resolution = resolve_precedence_chain(
plan_profile=plan_name, plan_profile=plan_name,
action_profile=None, action_profile=None,
project_profile=None,
global_override=global_override, global_override=global_override,
profile_registry=registry, 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: 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) global_override: str | None = getattr(context, "global_override", None)
registry: dict[str, AutomationProfile] | None = getattr( registry: dict[str, AutomationProfile] | None = getattr(
context, "custom_registry", None 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( context.resolution = resolve_precedence_chain(
plan_profile=None, plan_profile=None,
action_profile=action_name, action_profile=action_name,
project_profile=None,
global_override=global_override, global_override=global_override,
profile_registry=registry, profile_registry=registry,
) )
@when("I resolve the precedence chain with no plan and no action") @when('I resolve the precedence chain with no plan and no action project "{project_name}"')
def step_when_resolve_global_only(context: Context) -> None: def step_when_resolve_project_only(context: Context, project_name: str) -> None:
"""Resolve with neither plan nor action set.""" """Resolve with project set, plan and action absent."""
global_override: str | None = getattr(context, "global_override", None) global_override: str | None = getattr(context, "global_override", None)
registry: dict[str, AutomationProfile] | None = getattr( registry: dict[str, AutomationProfile] | None = getattr(
context, "custom_registry", None context, "custom_registry", None
@@ -165,12 +222,29 @@ def step_when_resolve_global_only(context: Context) -> None:
context.resolution = resolve_precedence_chain( context.resolution = resolve_precedence_chain(
plan_profile=None, plan_profile=None,
action_profile=None, action_profile=None,
project_profile=project_name,
global_override=global_override, global_override=global_override,
profile_registry=registry, 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( def step_when_resolve_empty_plan_with_action(
context: Context, action_name: str context: Context, action_name: str
) -> None: ) -> None:
@@ -179,17 +253,31 @@ def step_when_resolve_empty_plan_with_action(
context.resolution = resolve_precedence_chain( context.resolution = resolve_precedence_chain(
plan_profile="", plan_profile="",
action_profile=action_name, action_profile=action_name,
project_profile=None,
global_override=global_override, 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: def step_when_resolve_absent_plan_empty_action(context: Context) -> None:
"""Resolve with plan absent and empty string action (treated as absent).""" """Resolve with plan absent and empty string action (treated as absent)."""
global_override: str | None = getattr(context, "global_override", None) global_override: str | None = getattr(context, "global_override", None)
context.resolution = resolve_precedence_chain( context.resolution = resolve_precedence_chain(
plan_profile=None, plan_profile=None,
action_profile="", 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, 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}"') @then('the precedence source should be "{expected_source}"')
def step_then_precedence_source(context: Context, expected_source: str) -> None: 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 actual = context.resolution.source.value
assert actual == expected_source, ( assert actual == expected_source, (
f"Expected precedence source '{expected_source}', got '{actual}'" 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}"') @then('the resolution global_profile field should be "{expected}"')
def step_then_resolution_global_field(context: Context, expected: str) -> None: def step_then_resolution_global_field(context: Context, expected: str) -> None:
"""Assert the global_profile field on the resolution result.""" """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}"') @then('the PrecedenceSource GLOBAL value should be "{expected}"')
def step_then_precedence_source_global_value(context: Context, expected: str) -> None: def step_then_precedence_source_global_value(context: Context, expected: str) -> None:
"""Assert PrecedenceSource.GLOBAL has the expected string value.""" """Assert PrecedenceSource.GLOBAL has the expected string value."""
@@ -1,9 +1,9 @@
"""Automation Profile Precedence Chain for CleverAgents v3.5. """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: profile resolution:
plan > action > global plan > action > project > global
This module provides a dedicated, testable implementation of the precedence This module provides a dedicated, testable implementation of the precedence
chain that is independent of the full ``AutomationProfileService`` CRUD 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. 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. 2. **Action-level** — default profile set on the action template.
Overrides global; used when no plan-level override is set. Overrides project and global; used when no plan-level override is set.
3. **Global-level** — set via config key or 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"``. ``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 The precedence chain covers all 16 combinations of:
(plan set / not set) x (action set / not set) x (global set / default): (plan set/unset) x (action set/unset) x (project set/unset) x (global set/default)
+-------+--------+--------+------------------+ +-------+--------+---------+--------+------------------+
| plan | action | global | resolved source | | plan | action | project | global | resolved source |
+=======+========+========+==================+ +=======+========+=========+========+==================+
| set | set | set | plan | | set | set | set | set | plan |
+-------+--------+--------+------------------+ +-------+--------+---------+--------+------------------+
| set | set | default| plan | | set | set | set | default| plan |
+-------+--------+--------+------------------+ +-------+--------+---------+--------+------------------+
| set | unset | set | plan | | set | set | unset | set | plan |
+-------+--------+--------+------------------+ +-------+--------+---------+--------+------------------+
| set | unset | default| plan | | set | set | unset | default| plan |
+-------+--------+--------+------------------+ +-------+--------+---------+--------+------------------+
| unset | set | set | action | | set | unset | set | set | plan |
+-------+--------+--------+------------------+ +-------+--------+---------+--------+------------------+
| unset | set | default| action | | set | unset | set | default| plan |
+-------+--------+--------+------------------+ +-------+--------+---------+--------+------------------+
| unset | unset | set | global | | set | unset | unset | set | plan |
+-------+--------+--------+------------------+ +-------+--------+---------+--------+------------------+
| unset | unset | default| global (manual) | | 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". Based on ``docs/specification.md`` Section "Automation Profiles".
""" """
@@ -71,11 +89,13 @@ _DEFAULT_GLOBAL_PROFILE = "manual"
class PrecedenceSource(StrEnum): class PrecedenceSource(StrEnum):
"""Indicates which level of the precedence chain resolved the profile. """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" PLAN = "plan"
ACTION = "action" ACTION = "action"
PROJECT = "project"
GLOBAL = "global" GLOBAL = "global"
@@ -89,6 +109,7 @@ class PrecedenceResolution:
profile_name: The resolved profile name string. profile_name: The resolved profile name string.
plan_profile: The plan-level override (may be ``None``). plan_profile: The plan-level override (may be ``None``).
action_profile: The action-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. global_profile: The effective global profile name used as fallback.
""" """
@@ -97,6 +118,7 @@ class PrecedenceResolution:
profile_name: str profile_name: str
plan_profile: str | None plan_profile: str | None
action_profile: str | None action_profile: str | None
project_profile: str | None
global_profile: str global_profile: str
@@ -129,12 +151,13 @@ def resolve_precedence_chain(
*, *,
plan_profile: str | None = None, plan_profile: str | None = None,
action_profile: str | None = None, action_profile: str | None = None,
project_profile: str | None = None,
global_override: str | None = None, global_override: str | None = None,
profile_registry: dict[str, AutomationProfile] | None = None, profile_registry: dict[str, AutomationProfile] | None = None,
) -> PrecedenceResolution: ) -> PrecedenceResolution:
"""Resolve the effective automation profile using the precedence chain. """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 The resolution is logged at ``DEBUG`` level so callers can trace
which level provided the profile. which level provided the profile.
@@ -143,6 +166,8 @@ def resolve_precedence_chain(
``None`` or empty string means no plan-level override. ``None`` or empty string means no plan-level override.
action_profile: Profile name set on the action template. action_profile: Profile name set on the action template.
``None`` or empty string means no action-level override. ``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. global_override: Explicit global profile name from config service.
Falls back to ``CLEVERAGENTS_AUTOMATION_PROFILE`` env var, Falls back to ``CLEVERAGENTS_AUTOMATION_PROFILE`` env var,
then ``"manual"``. then ``"manual"``.
@@ -169,6 +194,10 @@ def resolve_precedence_chain(
>>> result.source >>> result.source
<PrecedenceSource.ACTION: 'action'> <PrecedenceSource.ACTION: 'action'>
>>> result = resolve_precedence_chain(project_profile="trusted")
>>> result.source
<PrecedenceSource.PROJECT: 'project'>
>>> result = resolve_precedence_chain() >>> result = resolve_precedence_chain()
>>> result.source >>> result.source
<PrecedenceSource.GLOBAL: 'global'> <PrecedenceSource.GLOBAL: 'global'>
@@ -186,26 +215,33 @@ def resolve_precedence_chain(
norm_action = action_profile.strip() if isinstance(action_profile, str) else None norm_action = action_profile.strip() if isinstance(action_profile, str) else None
norm_action = norm_action if norm_action 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) 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: if norm_plan is not None:
chosen_name = norm_plan chosen_name = norm_plan
source = PrecedenceSource.PLAN source = PrecedenceSource.PLAN
elif norm_action is not None: elif norm_action is not None:
chosen_name = norm_action chosen_name = norm_action
source = PrecedenceSource.ACTION source = PrecedenceSource.ACTION
elif norm_project is not None:
chosen_name = norm_project
source = PrecedenceSource.PROJECT
else: else:
chosen_name = effective_global chosen_name = effective_global
source = PrecedenceSource.GLOBAL source = PrecedenceSource.GLOBAL
logger.debug( logger.debug(
"Automation profile precedence chain resolved: " "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, chosen_name,
source.value, source.value,
norm_plan, norm_plan,
norm_action, norm_action,
norm_project,
effective_global, effective_global,
) )
@@ -217,5 +253,6 @@ def resolve_precedence_chain(
profile_name=chosen_name, profile_name=chosen_name,
plan_profile=norm_plan, plan_profile=norm_plan,
action_profile=norm_action, action_profile=norm_action,
project_profile=norm_project,
global_profile=effective_global, global_profile=effective_global,
) )