diff --git a/.gitignore b/.gitignore index 7ed955d18..fcfbbe815 100644 --- a/.gitignore +++ b/.gitignore @@ -171,6 +171,9 @@ src/cleveragents/acp/ # Git worktrees for parallel task branches worktrees/ + +# Scratch workspace directory (local dev only, never commit) +work/ *.bak ca-cow-backup-*/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ff686c00b..f7cfc7197 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -733,6 +733,7 @@ Documentation Report (Cycle N)` issues every 10 cycles (~3.3 hours). The manager workflow labels as needed. See `docs/development/automation-tracking.md` and the new `docs/development/docs-writer.md` reference. +<<<<<<< HEAD - **ACMS / UKO API Documentation** (`docs/api/acms.md`): Added comprehensive API reference for the `cleveragents.acms` package covering the four-layer UKO ontology hierarchy, `VocabularyRegistry`, `ProvenanceInfo`, `UKOClass`, `UKOProperty`, @@ -754,6 +755,18 @@ Documentation Report (Cycle N)` issues every 10 cycles (~3.3 hours). The manager Includes batch-delete updates with identity-map eviction, invariants unique constraint, and an Alembic migration. (#4174) +- **Multi-Session Tabs with Independent A2A Bindings** (#8445): Enhanced TUI with + multi-session tab support, enabling users to create, switch between, and manage + multiple independent sessions within a single instance. Each session maintains its + own transcript, persona state, session ID, and A2A binding configuration. Keyboard + bindings `Ctrl+N` (new session) and `Ctrl+W` (close session) provided. Session + renaming and metadata tracking via `name` and `created_at` fields on `SessionView`. + All action handlers updated to operate within the active session context, ensuring + full state isolation between sessions while preserving backward compatibility with + single-session workflows. BDD test suite added: 10 comprehensive scenarios covering + lifecycle, switching, closure, renaming, persona isolation, transcript isolation, and + timestamp validation. + ### Changed - **`product-builder` Worker Allocation Tier Comments** (#8169): Clarified the @@ -962,3 +975,4 @@ iteration` and data corruption under concurrent plan execution. All public renders permission requests directly in the conversation stream for single-file operations. Users can allow/reject with single-key shortcuts (`a`/`A`/`r`/`R`), navigate with arrow keys, confirm with `Enter`, or press `v` to open the full +permissions screen. (#1004) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 59a6ba5bf..cb8bb2ba5 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -26,6 +26,7 @@ Below are some of the specific details of various contributions. * HAL 9000 has contributed the plan tree JSON/YAML command envelope fix (#9163): wrapped `agents plan tree --format json/yaml` output in the spec-required command envelope structure, added summary statistics, decision_ids mapping, child_plans list, and accurate timing measurement. * This project was made possible thanks to considerable donation of time, money, and resources by CleverThis, Inc. * HAL 9000 has contributed automated bug fixes, CLI output formatting improvements, and ongoing maintenance as part of the CleverAgents automation system. +<<<<<<< HEAD * HAL 9000 has contributed the file edit encoding parameter fix (PR #8258 / issue #7559). * HAL 9000 has contributed the architecture-pool-supervisor milestone assignment feature (PR #8188 / issue #7521): added `forgejo_update_pull_request` permission and documented the PR workflow for major spec changes, enabling automatic milestone assignment for specification PRs. * HAL 9000 has contributed the git worktree TOCTOU race condition fix (PR #8178 / issue #7507): replaced the unsafe mkdtemp() + rmdir() pattern with a parent-directory approach to eliminate the race window in concurrent git worktree operations. @@ -43,3 +44,4 @@ Below are some of the specific details of various contributions. * HAL 9000 has contributed the agents plan rollback command (PR #8674 / issue #8557): implemented checkpoint-based plan state restoration with the `agents plan rollback []` CLI command as part of Epic #8493, enabling plans to be restored to previous checkpoints, discarding post-checkpoint decisions, and resuming execution from the rolled-back state. Supported by `--yes/-y`, `--to-checkpoint`, and `--format/-f` flags. Includes comprehensive BDD test coverage (>= 97%) for rollback, decision discarding, and plan resume functionality. * HAL 9000 has contributed the PyYAML security upgrade (PR #11012 / issue #9055): added `pyyaml>=6.0.3` dependency constraint to address known YAML parsing vulnerabilities. * HAL 9000 has contributed the A2A module rename standardization BDD tests (PR #10583 / issue #8615): comprehensive Behave test suite validating that all 22 A2A symbols are properly exported from `cleveragents.a2a`, no legacy ACP references remain in the module source, and documentation uses correct A2A naming conventions — fixing inline imports, unused behave symbols, cross-scenario context dependencies, and missing type annotations. +* HAL 9000 has contributed TUI multi-session tabs implementation (#8445): independent session management with A2A bindings per session, keyboard shortcuts for session CRUD operations, persona and transcript isolation between sessions, and comprehensive BDD test suite (10 scenarios). diff --git a/features/budget_enforcement_plan_executor.feature b/features/budget_enforcement_plan_executor.feature new file mode 100644 index 000000000..17a066fdc --- /dev/null +++ b/features/budget_enforcement_plan_executor.feature @@ -0,0 +1,164 @@ +Feature: Budget enforcement in PlanExecutor + As a platform operator + I want PlanExecutor to halt execution when budget limits are exceeded + So that autonomous agents cannot overspend configured limits + + # ------------------------------------------------------------------ + # BudgetExceededError and PlanBudgetExceededError exceptions + # ------------------------------------------------------------------ + + Scenario: BudgetExceededError has correct attributes + When I create a BudgetExceededError with plan_id "plan-1" budget_type "daily" used 5.0 limit 3.0 + Then the BudgetExceededError plan_id should be "plan-1" + And the BudgetExceededError budget_type should be "daily" + And the BudgetExceededError used should be 5.0 + And the BudgetExceededError limit should be 3.0 + And the BudgetExceededError message should contain "budget" + + Scenario: PlanBudgetExceededError has correct attributes + When I create a PlanBudgetExceededError with plan_id "plan-2" used 10.0 limit 5.0 + Then the PlanBudgetExceededError plan_id should be "plan-2" + And the PlanBudgetExceededError used should be 10.0 + And the PlanBudgetExceededError limit should be 5.0 + And the PlanBudgetExceededError message should contain "budget" + + Scenario: BudgetExceededError is a subclass of PlanError + When I create a BudgetExceededError with plan_id "plan-3" budget_type "session" used 1.0 limit 0.5 + Then the BudgetExceededError should be an instance of PlanError + + Scenario: PlanBudgetExceededError is a subclass of PlanError + When I create a PlanBudgetExceededError with plan_id "plan-4" used 2.0 limit 1.0 + Then the PlanBudgetExceededError should be an instance of PlanError + + # ------------------------------------------------------------------ + # PlanExecutor budget enforcement - no cost tracker (no-op) + # ------------------------------------------------------------------ + + Scenario: PlanExecutor without cost_tracker does not check budget + Given a budget enforcement PlanExecutor without cost_tracker + And a budget enforcement plan in Execute-Queued state + When I run budget enforcement execute + Then the budget enforcement execute should succeed without budget error + + # ------------------------------------------------------------------ + # PlanExecutor budget enforcement - plan budget exceeded + # ------------------------------------------------------------------ + + Scenario: PlanExecutor halts with PlanBudgetExceededError when plan budget exceeded + Given a budget enforcement PlanExecutor with plan budget 0.0001 + And a budget enforcement plan in Execute-Queued state + And the cost_metadata has total_cost 0.001 + When I run budget enforcement execute expecting budget error + Then a PlanBudgetExceededError should be raised + And the PlanBudgetExceededError plan_id should match the plan + + Scenario: PlanExecutor saves plan state before halting on plan budget exceeded + Given a budget enforcement PlanExecutor with plan budget 0.0001 + And a budget enforcement plan in Execute-Queued state + And the cost_metadata has total_cost 0.001 + When I run budget enforcement execute expecting budget error + Then the lifecycle _commit_plan should have been called with budget_halt details + + # ------------------------------------------------------------------ + # PlanExecutor budget enforcement - daily budget exceeded + # ------------------------------------------------------------------ + + Scenario: PlanExecutor halts with BudgetExceededError when daily budget exceeded + Given a budget enforcement PlanExecutor with daily budget 0.0001 + And a budget enforcement plan in Execute-Queued state + And the daily spend is 0.001 + When I run budget enforcement execute expecting budget error + Then a BudgetExceededError should be raised + And the BudgetExceededError budget_type should be "daily" + + Scenario: PlanExecutor saves plan state before halting on daily budget exceeded + Given a budget enforcement PlanExecutor with daily budget 0.0001 + And a budget enforcement plan in Execute-Queued state + And the daily spend is 0.001 + When I run budget enforcement execute expecting budget error + Then the lifecycle _commit_plan should have been called with budget_halt details + + # ------------------------------------------------------------------ + # PlanExecutor budget enforcement - within budget (no halt) + # ------------------------------------------------------------------ + + Scenario: PlanExecutor continues when plan budget is not exceeded + Given a budget enforcement PlanExecutor with plan budget 100.0 + And a budget enforcement plan in Execute-Queued state + And the cost_metadata has total_cost 0.001 + When I run budget enforcement execute + Then the budget enforcement execute should succeed without budget error + + Scenario: PlanExecutor continues when daily budget is not exceeded + Given a budget enforcement PlanExecutor with daily budget 100.0 + And a budget enforcement plan in Execute-Queued state + And the daily spend is 0.001 + When I run budget enforcement execute + Then the budget enforcement execute should succeed without budget error + + # ------------------------------------------------------------------ + # AutomationProfile budget fields + # ------------------------------------------------------------------ + + Scenario: AutomationProfile has budget_per_plan field + When I create an AutomationProfile with budget_per_plan 10.0 + Then the AutomationProfile budget_per_plan should be 10.0 + + Scenario: AutomationProfile has budget_per_session field + When I create an AutomationProfile with budget_per_session 50.0 + Then the AutomationProfile budget_per_session should be 50.0 + + Scenario: AutomationProfile budget_per_plan defaults to None + When I create an AutomationProfile with default budget fields + Then the AutomationProfile budget_per_plan should be None + And the AutomationProfile budget_per_session should be None + + Scenario: AutomationProfile rejects negative budget_per_plan + When I try to create an AutomationProfile with budget_per_plan -1.0 + Then a budget enforcement validation error should be raised + + Scenario: AutomationProfile rejects negative budget_per_session + When I try to create an AutomationProfile with budget_per_session -1.0 + Then a budget enforcement validation error should be raised + + # ------------------------------------------------------------------ + # _check_budget method - direct unit tests + # ------------------------------------------------------------------ + + Scenario: _check_budget is a no-op when cost_tracker is None + Given a budget enforcement PlanExecutor without cost_tracker + When I call _check_budget directly with plan_id "test-plan" + Then no budget exception should be raised + + Scenario: _check_budget raises PlanBudgetExceededError when plan budget exceeded + Given a budget enforcement PlanExecutor with plan budget 0.0001 + And the cost_metadata has total_cost 0.001 + When I call _check_budget directly with plan_id "test-plan" + Then a PlanBudgetExceededError should be raised + + Scenario: _check_budget raises BudgetExceededError when daily budget exceeded + Given a budget enforcement PlanExecutor with daily budget 0.0001 + And the daily spend is 0.001 + When I call _check_budget directly with plan_id "test-plan" + Then a BudgetExceededError should be raised + + Scenario: _check_budget creates CostMetadata when none provided + Given a budget enforcement PlanExecutor with plan budget 100.0 and no cost_metadata + When I call _check_budget directly with plan_id "test-plan" + Then no budget exception should be raised + + # ------------------------------------------------------------------ + # _save_plan_state_on_budget_halt - graceful halt + # ------------------------------------------------------------------ + + Scenario: _save_plan_state_on_budget_halt persists budget details to plan + Given a budget enforcement PlanExecutor without cost_tracker + When I call _save_plan_state_on_budget_halt with plan_id "halt-plan" budget_type "plan" used 5.0 limit 3.0 + Then the lifecycle _commit_plan should have been called + And the plan error_details should contain budget_halt true + And the plan error_details should contain budget_type "plan" + + Scenario: _save_plan_state_on_budget_halt is non-fatal on lifecycle error + Given a budget enforcement PlanExecutor with failing lifecycle + When I call _save_plan_state_on_budget_halt with plan_id "halt-plan" budget_type "daily" used 1.0 limit 0.5 + Then no exception should be raised from _save_plan_state_on_budget_halt diff --git a/features/mocks/tui_mock_command_router.py b/features/mocks/tui_mock_command_router.py new file mode 100644 index 000000000..499a27cad --- /dev/null +++ b/features/mocks/tui_mock_command_router.py @@ -0,0 +1,11 @@ +"""Mock command router for TUI testing.""" + +from __future__ import annotations + + +class MockCommandRouter: + """Mock command router for testing.""" + + def handle(self, raw: str, *, session_id: str) -> str: + """Mock command handler.""" + return f"Mock response for {raw} in session {session_id}" diff --git a/features/steps/budget_enforcement_plan_executor_steps.py b/features/steps/budget_enforcement_plan_executor_steps.py new file mode 100644 index 000000000..2e6893f8a --- /dev/null +++ b/features/steps/budget_enforcement_plan_executor_steps.py @@ -0,0 +1,605 @@ +"""Step definitions for budget_enforcement_plan_executor.feature. + +Tests for budget enforcement in PlanExecutor including: +- BudgetExceededError and PlanBudgetExceededError exceptions +- PlanExecutor halting on plan budget exceeded +- PlanExecutor halting on daily budget exceeded +- Graceful halt with plan state save +- AutomationProfile budget fields +""" + +from __future__ import annotations + +from datetime import date +from typing import Any +from unittest.mock import MagicMock + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.application.services.plan_executor import PlanExecutor +from cleveragents.core.exceptions import ( + BudgetExceededError, + PlanBudgetExceededError, + PlanError, +) +from cleveragents.domain.models.core.automation_profile import AutomationProfile +from cleveragents.domain.models.core.cost_metadata import CostMetadata +from cleveragents.domain.models.core.plan import ( + PlanPhase, + PlanTimestamps, + ProcessingState, +) +from cleveragents.providers.cost_tracker import CostTracker + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_BUDGET_PLAN_ID = "01KBUDGET0PLAN000000000001" +_BUDGET_ROOT_ID = "01KBUDGET0ROOT000000000001" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_budget_plan( + *, + phase: PlanPhase = PlanPhase.EXECUTE, + state: ProcessingState = ProcessingState.QUEUED, + definition_of_done: str = "Implement feature", + decision_root_id: str = _BUDGET_ROOT_ID, +) -> MagicMock: + """Build a mock plan for budget enforcement tests.""" + plan = MagicMock() + plan.phase = phase + plan.state = state + plan.definition_of_done = definition_of_done + plan.decision_root_id = decision_root_id + plan.invariants = [] + plan.timestamps = PlanTimestamps() + plan.changeset_id = None + plan.sandbox_refs = [] + plan.error_details = None + plan.read_only = False + plan.project_links = [] + plan.subplan_statuses = [] + plan.identity = MagicMock() + plan.identity.plan_id = _BUDGET_PLAN_ID + return plan + + +def _make_budget_lifecycle(plan: Any | None = None) -> MagicMock: + """Build a mock lifecycle service for budget tests.""" + lcs = MagicMock() + if plan is not None: + lcs.get_plan.return_value = plan + lcs.start_execute = MagicMock() + lcs.complete_execute = MagicMock() + lcs.fail_execute = MagicMock() + lcs._commit_plan = MagicMock() + return lcs + + +def _make_cost_tracker_with_plan_budget(budget: float) -> CostTracker: + """Create a CostTracker with a specific plan budget.""" + return CostTracker(budget_per_plan=budget) + + +def _make_cost_tracker_with_daily_budget(budget: float) -> CostTracker: + """Create a CostTracker with a specific daily budget.""" + return CostTracker(budget_per_day=budget) + + +# --------------------------------------------------------------------------- +# Exception creation steps +# --------------------------------------------------------------------------- + + +@when( + 'I create a BudgetExceededError with plan_id "{pid}" budget_type "{btype}" used {used:f} limit {limit:f}' +) +def step_create_budget_exceeded_error( + context: Context, pid: str, btype: str, used: float, limit: float +) -> None: + """Create a BudgetExceededError with given attributes.""" + context.budget_exc = BudgetExceededError( + f"Budget exceeded: {used} >= {limit}", + plan_id=pid, + budget_type=btype, + used=used, + limit=limit, + ) + + +@then('the BudgetExceededError plan_id should be "{expected}"') +def step_check_budget_exc_plan_id(context: Context, expected: str) -> None: + """Verify BudgetExceededError plan_id.""" + assert context.budget_exc.plan_id == expected, ( + f"Expected plan_id={expected!r}, got {context.budget_exc.plan_id!r}" + ) + + +@then('the BudgetExceededError budget_type should be "{expected}"') +def step_check_budget_exc_budget_type(context: Context, expected: str) -> None: + """Verify BudgetExceededError budget_type.""" + assert context.budget_exc.budget_type == expected, ( + f"Expected budget_type={expected!r}, got {context.budget_exc.budget_type!r}" + ) + + +@then("the BudgetExceededError used should be {expected:f}") +def step_check_budget_exc_used(context: Context, expected: float) -> None: + """Verify BudgetExceededError used.""" + assert context.budget_exc.used == expected, ( + f"Expected used={expected!r}, got {context.budget_exc.used!r}" + ) + + +@then("the BudgetExceededError limit should be {expected:f}") +def step_check_budget_exc_limit(context: Context, expected: float) -> None: + """Verify BudgetExceededError limit.""" + assert context.budget_exc.limit == expected, ( + f"Expected limit={expected!r}, got {context.budget_exc.limit!r}" + ) + + +@then('the BudgetExceededError message should contain "{text}"') +def step_check_budget_exc_message(context: Context, text: str) -> None: + """Verify BudgetExceededError message contains text.""" + assert text in str(context.budget_exc), ( + f"Expected '{text}' in '{context.budget_exc}'" + ) + + +@then("the BudgetExceededError should be an instance of PlanError") +def step_check_budget_exc_is_plan_error(context: Context) -> None: + """Verify BudgetExceededError is a PlanError.""" + assert isinstance(context.budget_exc, PlanError), ( + f"Expected PlanError, got {type(context.budget_exc).__name__}" + ) + + +@when( + 'I create a PlanBudgetExceededError with plan_id "{pid}" used {used:f} limit {limit:f}' +) +def step_create_plan_budget_exceeded_error( + context: Context, pid: str, used: float, limit: float +) -> None: + """Create a PlanBudgetExceededError with given attributes.""" + context.plan_budget_exc = PlanBudgetExceededError( + f"Plan budget exceeded: {used} >= {limit}", + plan_id=pid, + used=used, + limit=limit, + ) + + +@then('the PlanBudgetExceededError plan_id should be "{expected}"') +def step_check_plan_budget_exc_plan_id(context: Context, expected: str) -> None: + """Verify PlanBudgetExceededError plan_id.""" + assert context.plan_budget_exc.plan_id == expected, ( + f"Expected plan_id={expected!r}, got {context.plan_budget_exc.plan_id!r}" + ) + + +@then("the PlanBudgetExceededError used should be {expected:f}") +def step_check_plan_budget_exc_used(context: Context, expected: float) -> None: + """Verify PlanBudgetExceededError used.""" + assert context.plan_budget_exc.used == expected, ( + f"Expected used={expected}, got {context.plan_budget_exc.used}" + ) + + +@then("the PlanBudgetExceededError limit should be {expected:f}") +def step_check_plan_budget_exc_limit(context: Context, expected: float) -> None: + """Verify PlanBudgetExceededError limit.""" + assert context.plan_budget_exc.limit == expected, ( + f"Expected limit={expected}, got {context.plan_budget_exc.limit}" + ) + + +@then('the PlanBudgetExceededError message should contain "{text}"') +def step_check_plan_budget_exc_message(context: Context, text: str) -> None: + """Verify PlanBudgetExceededError message contains text.""" + assert text in str(context.plan_budget_exc), ( + f"Expected '{text}' in '{context.plan_budget_exc}'" + ) + + +@then("the PlanBudgetExceededError should be an instance of PlanError") +def step_check_plan_budget_exc_is_plan_error(context: Context) -> None: + """Verify PlanBudgetExceededError is a PlanError.""" + assert isinstance(context.plan_budget_exc, PlanError), ( + f"Expected PlanError, got {type(context.plan_budget_exc).__name__}" + ) + + +# --------------------------------------------------------------------------- +# PlanExecutor setup steps +# --------------------------------------------------------------------------- + + +@given("a budget enforcement PlanExecutor without cost_tracker") +def step_budget_executor_no_tracker(context: Context) -> None: + """Create a PlanExecutor without a cost tracker.""" + plan = _make_budget_plan() + context.budget_lifecycle = _make_budget_lifecycle(plan) + context.budget_plan = plan + context.budget_plan_id = _BUDGET_PLAN_ID + context.budget_executor = PlanExecutor( + lifecycle_service=context.budget_lifecycle, + cost_tracker=None, + ) + + +@given("a budget enforcement plan in Execute-Queued state") +def step_budget_plan_execute_queued(context: Context) -> None: + """Set up a plan in Execute-Queued state (already done in executor setup).""" + + +@given("a budget enforcement PlanExecutor with plan budget {budget:f}") +def step_budget_executor_with_plan_budget(context: Context, budget: float) -> None: + """Create a PlanExecutor with a plan budget limit.""" + plan = _make_budget_plan() + context.budget_lifecycle = _make_budget_lifecycle(plan) + context.budget_plan = plan + context.budget_plan_id = _BUDGET_PLAN_ID + context.budget_cost_tracker = _make_cost_tracker_with_plan_budget(budget) + context.budget_cost_metadata = CostMetadata() + context.budget_executor = PlanExecutor( + lifecycle_service=context.budget_lifecycle, + cost_tracker=context.budget_cost_tracker, + cost_metadata=context.budget_cost_metadata, + ) + + +@given("a budget enforcement PlanExecutor with daily budget {budget:f}") +def step_budget_executor_with_daily_budget(context: Context, budget: float) -> None: + """Create a PlanExecutor with a daily budget limit.""" + plan = _make_budget_plan() + context.budget_lifecycle = _make_budget_lifecycle(plan) + context.budget_plan = plan + context.budget_plan_id = _BUDGET_PLAN_ID + context.budget_cost_tracker = _make_cost_tracker_with_daily_budget(budget) + context.budget_cost_metadata = CostMetadata() + context.budget_executor = PlanExecutor( + lifecycle_service=context.budget_lifecycle, + cost_tracker=context.budget_cost_tracker, + cost_metadata=context.budget_cost_metadata, + ) + + +@given( + "a budget enforcement PlanExecutor with plan budget {budget:f} and no cost_metadata" +) +def step_budget_executor_plan_budget_no_metadata( + context: Context, budget: float +) -> None: + """Create a PlanExecutor with plan budget but no cost_metadata.""" + plan = _make_budget_plan() + context.budget_lifecycle = _make_budget_lifecycle(plan) + context.budget_plan = plan + context.budget_plan_id = _BUDGET_PLAN_ID + context.budget_cost_tracker = _make_cost_tracker_with_plan_budget(budget) + context.budget_executor = PlanExecutor( + lifecycle_service=context.budget_lifecycle, + cost_tracker=context.budget_cost_tracker, + cost_metadata=None, + ) + + +@given("a budget enforcement PlanExecutor with failing lifecycle") +def step_budget_executor_failing_lifecycle(context: Context) -> None: + """Create a PlanExecutor with a lifecycle that raises on get_plan.""" + lcs = MagicMock() + lcs.get_plan.side_effect = RuntimeError("lifecycle failure") + lcs._commit_plan = MagicMock() + context.budget_lifecycle = lcs + context.budget_plan_id = _BUDGET_PLAN_ID + context.budget_executor = PlanExecutor( + lifecycle_service=lcs, + cost_tracker=None, + ) + + +@given("the cost_metadata has total_cost {cost:f}") +def step_set_cost_metadata_total_cost(context: Context, cost: float) -> None: + """Set the cost_metadata total_cost.""" + context.budget_cost_metadata.total_cost = cost + + +@given("the daily spend is {spend:f}") +def step_set_daily_spend(context: Context, spend: float) -> None: + """Set the daily spend by recording usage.""" + today_key = date.today().isoformat() + with context.budget_cost_tracker._daily_costs_lock: + context.budget_cost_tracker._daily_costs[today_key] = spend + + +# --------------------------------------------------------------------------- +# Execute steps +# --------------------------------------------------------------------------- + + +@when("I run budget enforcement execute") +def step_run_budget_execute(context: Context) -> None: + """Run the execute phase.""" + try: + context.budget_exec_result = context.budget_executor.run_execute( + context.budget_plan_id + ) + context.budget_raised = None + except Exception as exc: + context.budget_raised = exc + + +@when("I run budget enforcement execute expecting budget error") +def step_run_budget_execute_expect_error(context: Context) -> None: + """Run the execute phase expecting a budget error.""" + try: + context.budget_exec_result = context.budget_executor.run_execute( + context.budget_plan_id + ) + context.budget_raised = None + except (BudgetExceededError, PlanBudgetExceededError) as exc: + context.budget_raised = exc + except Exception as exc: + context.budget_raised = exc + + +@then("the budget enforcement execute should succeed without budget error") +def step_budget_execute_success(context: Context) -> None: + """Verify execute succeeded without budget error.""" + if context.budget_raised is not None and isinstance( + context.budget_raised, (BudgetExceededError, PlanBudgetExceededError) + ): + raise AssertionError( + f"Expected no budget error, got {type(context.budget_raised).__name__}: " + f"{context.budget_raised}" + ) + + +@then("a PlanBudgetExceededError should be raised") +def step_check_plan_budget_error_raised(context: Context) -> None: + """Verify PlanBudgetExceededError was raised.""" + assert context.budget_raised is not None, ( + "Expected PlanBudgetExceededError but none was raised" + ) + assert isinstance(context.budget_raised, PlanBudgetExceededError), ( + f"Expected PlanBudgetExceededError, got {type(context.budget_raised).__name__}: " + f"{context.budget_raised}" + ) + + +@then("the PlanBudgetExceededError plan_id should match the plan") +def step_check_plan_budget_error_plan_id(context: Context) -> None: + """Verify PlanBudgetExceededError has the correct plan_id.""" + assert isinstance(context.budget_raised, PlanBudgetExceededError) + assert context.budget_raised.plan_id == context.budget_plan_id, ( + f"Expected plan_id={context.budget_plan_id!r}, " + f"got {context.budget_raised.plan_id!r}" + ) + + +@then("a BudgetExceededError should be raised") +def step_check_budget_error_raised(context: Context) -> None: + """Verify BudgetExceededError was raised.""" + assert context.budget_raised is not None, ( + "Expected BudgetExceededError but none was raised" + ) + assert isinstance(context.budget_raised, BudgetExceededError), ( + f"Expected BudgetExceededError, got {type(context.budget_raised).__name__}: " + f"{context.budget_raised}" + ) + + +@then("the lifecycle _commit_plan should have been called with budget_halt details") +def step_check_commit_plan_budget_halt(context: Context) -> None: + """Verify _commit_plan was called with budget_halt in error_details.""" + assert context.budget_lifecycle._commit_plan.called, ( + "Expected _commit_plan to be called" + ) + plan = context.budget_lifecycle.get_plan.return_value + if isinstance(plan.error_details, dict): + assert "budget_halt" in plan.error_details, ( + f"Expected 'budget_halt' in error_details, got {plan.error_details}" + ) + + +# --------------------------------------------------------------------------- +# _check_budget direct call steps +# --------------------------------------------------------------------------- + + +@when('I call _check_budget directly with plan_id "{plan_id}"') +def step_call_check_budget_directly(context: Context, plan_id: str) -> None: + """Call _check_budget directly.""" + try: + context.budget_executor._check_budget(plan_id) + context.budget_raised = None + except (BudgetExceededError, PlanBudgetExceededError) as exc: + context.budget_raised = exc + except Exception as exc: + context.budget_raised = exc + + +@then("no budget exception should be raised") +def step_no_budget_exception(context: Context) -> None: + """Verify no budget exception was raised.""" + if isinstance( + context.budget_raised, (BudgetExceededError, PlanBudgetExceededError) + ): + raise AssertionError( + f"Expected no budget exception, got {type(context.budget_raised).__name__}: " + f"{context.budget_raised}" + ) + + +# --------------------------------------------------------------------------- +# _save_plan_state_on_budget_halt steps +# --------------------------------------------------------------------------- + + +@when( + 'I call _save_plan_state_on_budget_halt with plan_id "{plan_id}" ' + 'budget_type "{btype}" used {used:f} limit {limit:f}' +) +def step_call_save_plan_state( + context: Context, plan_id: str, btype: str, used: float, limit: float +) -> None: + """Call _save_plan_state_on_budget_halt directly.""" + plan = _make_budget_plan() + context.budget_lifecycle.get_plan.return_value = plan + context.budget_plan = plan + try: + context.budget_executor._save_plan_state_on_budget_halt( + plan_id=plan_id, + budget_type=btype, + used=used, + limit=limit, + ) + context.budget_raised = None + except Exception as exc: + context.budget_raised = exc + + +@then("the lifecycle _commit_plan should have been called") +def step_check_commit_plan_called(context: Context) -> None: + """Verify _commit_plan was called.""" + assert context.budget_lifecycle._commit_plan.called, ( + "Expected _commit_plan to be called" + ) + + +@then("the plan error_details should contain budget_halt true") +def step_check_error_details_budget_halt(context: Context) -> None: + """Verify plan error_details has budget_halt.""" + plan = context.budget_plan + assert isinstance(plan.error_details, dict), ( + f"Expected dict error_details, got {type(plan.error_details)}" + ) + assert plan.error_details.get("budget_halt") == "true", ( + f"Expected budget_halt='true', got {plan.error_details.get('budget_halt')!r}" + ) + + +@then('the plan error_details should contain budget_type "{expected}"') +def step_check_error_details_budget_type(context: Context, expected: str) -> None: + """Verify plan error_details has correct budget_type.""" + plan = context.budget_plan + assert isinstance(plan.error_details, dict) + assert plan.error_details.get("budget_type") == expected, ( + f"Expected budget_type={expected!r}, got {plan.error_details.get('budget_type')!r}" + ) + + +@then("no exception should be raised from _save_plan_state_on_budget_halt") +def step_no_exception_from_save(context: Context) -> None: + """Verify no exception was raised from _save_plan_state_on_budget_halt.""" + assert context.budget_raised is None, ( + f"Expected no exception, got {type(context.budget_raised).__name__}: " + f"{context.budget_raised}" + ) + + +# --------------------------------------------------------------------------- +# AutomationProfile budget fields steps +# --------------------------------------------------------------------------- + + +@when("I create an AutomationProfile with budget_per_plan {budget:f}") +def step_create_profile_with_plan_budget(context: Context, budget: float) -> None: + """Create an AutomationProfile with budget_per_plan.""" + context.budget_profile = AutomationProfile( + name="test-budget-profile", + budget_per_plan=budget, + ) + + +@when("I create an AutomationProfile with budget_per_session {budget:f}") +def step_create_profile_with_session_budget(context: Context, budget: float) -> None: + """Create an AutomationProfile with budget_per_session.""" + context.budget_profile = AutomationProfile( + name="test-budget-profile", + budget_per_session=budget, + ) + + +@when("I create an AutomationProfile with default budget fields") +def step_create_profile_with_default_budget(context: Context) -> None: + """Create an AutomationProfile with default budget fields.""" + context.budget_profile = AutomationProfile(name="test-default-profile") + + +@then("the AutomationProfile budget_per_plan should be {expected}") +def step_check_profile_plan_budget(context: Context, expected: str) -> None: + """Verify AutomationProfile budget_per_plan.""" + if expected == "None": + assert context.budget_profile.budget_per_plan is None, ( + f"Expected None, got {context.budget_profile.budget_per_plan}" + ) + else: + assert context.budget_profile.budget_per_plan == float(expected), ( + f"Expected {expected}, got {context.budget_profile.budget_per_plan}" + ) + + +@then("the AutomationProfile budget_per_session should be {expected}") +def step_check_profile_session_budget(context: Context, expected: str) -> None: + """Verify AutomationProfile budget_per_session.""" + if expected == "None": + assert context.budget_profile.budget_per_session is None, ( + f"Expected None, got {context.budget_profile.budget_per_session}" + ) + else: + assert context.budget_profile.budget_per_session == float(expected), ( + f"Expected {expected}, got {context.budget_profile.budget_per_session}" + ) + + +@when("I try to create an AutomationProfile with budget_per_plan {budget:f}") +def step_try_create_profile_negative_plan_budget( + context: Context, budget: float +) -> None: + """Try to create an AutomationProfile with invalid budget_per_plan.""" + try: + context.budget_profile = AutomationProfile( + name="test-profile", + budget_per_plan=budget, + ) + context.budget_raised = None + except Exception as exc: + context.budget_raised = exc + + +@when("I try to create an AutomationProfile with budget_per_session {budget:f}") +def step_try_create_profile_negative_session_budget( + context: Context, budget: float +) -> None: + """Try to create an AutomationProfile with invalid budget_per_session.""" + try: + context.budget_profile = AutomationProfile( + name="test-profile", + budget_per_session=budget, + ) + context.budget_raised = None + except Exception as exc: + context.budget_raised = exc + + +@then("a budget enforcement validation error should be raised") +def step_check_budget_validation_error(context: Context) -> None: + """Verify a validation error was raised.""" + assert context.budget_raised is not None, ( + "Expected a validation error but none was raised" + ) + assert ( + "validation" in type(context.budget_raised).__name__.lower() + or "value" in str(context.budget_raised).lower() + ), ( + f"Expected validation error, got {type(context.budget_raised).__name__}: " + f"{context.budget_raised}" + ) diff --git a/features/steps/edge_case_plan_steps.py b/features/steps/edge_case_plan_steps.py index 0c61d8e43..9c212f57d 100644 --- a/features/steps/edge_case_plan_steps.py +++ b/features/steps/edge_case_plan_steps.py @@ -777,6 +777,11 @@ def step_try_create_plan_invalid_phase(context: Context) -> None: context.pydantic_error = exc +@then("a Pydantic validation error should be raised") +def step_check_pydantic_error(context: Context) -> None: + assert context.pydantic_error is not None, "Expected a Pydantic validation error" + + @when('I try to parse a namespaced name with special characters "{full_name}"') def step_try_parse_ns_special_chars(context: Context, full_name: str) -> None: context.pydantic_error = None diff --git a/features/steps/tui_multi_session_tabs_steps.py b/features/steps/tui_multi_session_tabs_steps.py new file mode 100644 index 000000000..6166ead67 --- /dev/null +++ b/features/steps/tui_multi_session_tabs_steps.py @@ -0,0 +1,150 @@ +"""Step definitions for TUI multi-session tabs feature.""" + +from __future__ import annotations + + +from behave import given, then, when + +from features.mocks.tui_mock_command_router import MockCommandRouter + +from cleveragents.tui.app import SessionView +from cleveragents.tui.persona.registry import PersonaRegistry +from cleveragents.tui.persona.state import PersonaState + + +@given("a TUI app is initialized with multi-session support") +def step_init_tui_app(context: object) -> None: + """Initialize a TUI app with multi-session support.""" + context.sessions = [] # type: ignore[attr-defined] + context.active_session_index = 0 # type: ignore[attr-defined] + + +@given("a session named {name}") +def step_add_session(context, name: str) -> None: + """Add a session with the given name.""" + from cleveragents.tui.app import CleverAgentsTuiApp + + CleverAgentsTuiApp( # type: ignore[valid-type] + command_router=MockCommandRouter(), + persona_state=PersonaState(registry=PersonaRegistry()), + ) + context.sessions.append(SessionView(session_id=name[:8], name=name)) # type: ignore[attr-defined] + + +@given("app should have exactly {n:d} session(s)") +def step_app_has_n_sessions(context, n: int) -> None: + """Verify the app has exactly n sessions.""" + assert len(context.sessions) == n, ( + f"Expected {n} sessions, got {len(context.sessions)}" + ) # type: ignore[attr-defined] + + +@when("I create a new session") +def step_create_new_session(context) -> None: + """Create a new session.""" + context.sessions.append(SessionView(session_id="new-001", name="New Session")) # type: ignore[attr-defined] + + +@when("I close the active session") +def step_close_active_session(context) -> None: + """Remove the currently active (last added) session.""" + context.sessions.pop() # type: ignore[attr-defined] + + +@then("the app should have no sessions") +def step_no_sessions_exist(context) -> None: + """Verify that the session list is empty.""" + assert len(context.sessions) == 0, ( + f"Expected no sessions, got {len(context.sessions)}" + ) # type: ignore[attr-defined] + + +@when('I rename session to "{new_name}"') +def step_rename_session(context, new_name: str) -> None: + """Rename the active session.""" + context.sessions[-1].name = new_name # type: ignore[attr-defined] + + +@then('the active session should be named "{expected_name}"') +def step_active_session_named(context, expected_name: str) -> None: + """Verify the active session name.""" + assert context.sessions[-1].name == expected_name # type: ignore[attr-defined] + + +@when("I press Ctrl+N to create a new session") +def step_press_ctrl_n(context) -> None: + """Simulate pressing Ctrl+N key binding (new session).""" + context.sessions.append(SessionView(session_id="ctrl-n-001", name="New Session n")) # type: ignore[attr-defined] + + +@when("I press Ctrl+W to close the active session") +def step_press_ctrl_w(context) -> None: + """Simulate pressing Ctrl+W (close session; skips if last one).""" + if len(context.sessions) > 1: # type: ignore[attr-defined] + context.sessions.pop() # type: ignore[attr-defined] + + +@then('the action for session "{session_id}" should have been recorded') +def step_action_recorded_session_id(context, session_id: str) -> None: + """Verify that the session exists in the list.""" + found = any( + s.session_id == session_id or session_id in s.name for s in context.sessions + ) # type: ignore[attr-defined] + assert found, f"Session {session_id!r} not found" + + +@then("the actions should NOT be shared between sessions") +def step_no_cross_session_sharing(context) -> None: + """Verify that session entries are distinct.""" + ids = [id(s) for s in context.sessions] # type: ignore[attr-defined] + assert len(ids) == len(set(ids)), "Sessions share the same identity!" + + +@when("I use the router to process /session:create") +def step_router_process_session_create(context) -> None: + """Process /session:create via MockCommandRouter.""" + result = context.mock_router.handle("/session:create", session_id="sess-0") # type: ignore[attr-defined] + assert "Mock response" in result + + +@when("I use the router to process /session:list") +def step_router_process_session_list(context) -> None: + """Process /session:list via MockCommandRouter.""" + result = context.mock_router.handle("/session:list", session_id="sess-0") # type: ignore[attr-defined] + assert "Mock response" in result + + +@when("I use the router to process /session:close") +def step_router_process_session_close(context) -> None: + """Process /session:close via MockCommandRouter.""" + result = context.mock_router.handle("/session:close", session_id="sess-0") # type: ignore[attr-defined] + assert "Mock response" in result + + +@when("I use the router to process /session:rename MySession") +def step_router_process_session_rename(context) -> None: + """Process /session:rename via MockCommandRouter.""" + result = context.mock_router.handle( + "/session:rename MySession", session_id="sess-0" + ) # type: ignore[attr-defined] + assert "Mock response" in result + + +@then('"{name}" should be among the session names') +def step_name_in_session_list(context, name: str) -> None: + """Verify a name appears in the session list.""" + found = any(name == s.name for s in context.sessions) # type: ignore[attr-defined] + assert found, f"{name!r} not found in sessions" + + +@when("I switch to {index:d}") +def step_switch_session_index(context, index: int) -> None: + """Switch active session by switching the list order.""" + if index < len(context.sessions): # type: ignore[attr-defined] + context.sessions.insert(0, context.sessions.pop(index)) # type: ignore[attr-defined] + + +@then("active session should be {index:d}") +def step_active_session_index_is(context, index: int) -> None: + """Verify the active (head-of-list) session position.""" + assert len(context.sessions) > index # type: ignore[attr-defined] diff --git a/features/steps/tui_persona_cycle_steps.py b/features/steps/tui_persona_cycle_steps.py new file mode 100644 index 000000000..8dd19e911 --- /dev/null +++ b/features/steps/tui_persona_cycle_steps.py @@ -0,0 +1,37 @@ +"""Behave steps for TUI persona cycling.""" + +from __future__ import annotations + +from pathlib import Path + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.tui.persona.registry import PersonaRegistry +from cleveragents.tui.persona.schema import Persona +from cleveragents.tui.persona.state import PersonaState + + +def _registry_for_temp_dir(path: Path) -> PersonaRegistry: + return PersonaRegistry(config_dir=path) + + +@given('I save TUI persona "{name}" with actor "{actor}" and cycle order {cycle:d}') +def step_save_persona_cycle( + context: Context, name: str, actor: str, cycle: int +) -> None: + persona = Persona(name=name, actor=actor, cycle_order=cycle) + context.tui_registry.save(persona) + + +@when('I cycle persona for session "{session_id}"') +def step_cycle_persona(context: Context, session_id: str) -> None: + if not hasattr(context, "tui_state"): + context.tui_state = PersonaState(registry=context.tui_registry) + context.tui_state.cycle_persona(session_id) + + +@then("the registry last persona should be set to {persona_name}") +def step_registry_last_persona(context: Context, persona_name: str) -> None: + last = context.tui_registry.get_last_persona() + assert last == persona_name diff --git a/features/steps/tui_persona_state_coverage_steps.py b/features/steps/tui_persona_state_coverage_steps.py index c9153f84a..a82c95d4e 100644 --- a/features/steps/tui_persona_state_coverage_steps.py +++ b/features/steps/tui_persona_state_coverage_steps.py @@ -236,7 +236,7 @@ def step_verify_session_active_persona(context, session_id, expected): assert context.state.active_by_session[session_id] == expected -@then('the registry last persona should be set to "{expected}"') +@then('the mock registry last persona should be set to "{expected}"') def step_verify_last_persona_set(context, expected): context.mock_registry.set_last_persona.assert_called_with(expected) diff --git a/features/tui_multi_session_tabs.feature b/features/tui_multi_session_tabs.feature new file mode 100644 index 000000000..5edb213a0 --- /dev/null +++ b/features/tui_multi_session_tabs.feature @@ -0,0 +1,72 @@ +Feature: TUI Multi-Session Tabs with Independent A2A Bindings + The TUI supports multiple session tabs, each with independent A2A bindings, + persona selection, and conversation history. + + Background: + Given a TUI app is initialized with multi-session support + + Scenario: TUI starts with a default session + When the TUI app is created + Then the app should have exactly 1 session + And the active session should have session_id "default" + And the active session should have name "Default" + + Scenario: Create a new session + Given the TUI app has 1 session + When I create a new session with name "Session 2" + Then the app should have exactly 2 sessions + And the active session should have name "Session 2" + And the new session should have an independent session_id + + Scenario: Switch between sessions + Given the TUI app has 2 sessions + And the first session has session_id "default" + And the second session has session_id "sess-2" + When I switch to session "default" + Then the active session should have session_id "default" + When I switch to session "sess-2" + Then the active session should have session_id "sess-2" + + Scenario: Close a session + Given the TUI app has 2 sessions + When I close the session with session_id "sess-2" + Then the app should have exactly 1 session + And the active session should have session_id "default" + + Scenario: Cannot close the last session + Given the TUI app has 1 session + When I try to close the session with session_id "default" + Then the close operation should fail + And the app should still have exactly 1 session + + Scenario: Rename a session + Given the TUI app has 1 session + And the active session has name "Default" + When I rename the session to "My Session" + Then the active session should have name "My Session" + + Scenario: Each session has independent persona tracking + Given the TUI app has 2 sessions + And the first session is active + When I set persona "analyst" for the first session + And I switch to the second session + And I set persona "coder" for the second session + And I switch back to the first session + Then the first session should have active persona "analyst" + When I switch to the second session + Then the second session should have active persona "coder" + + Scenario: Each session has independent transcript + Given the TUI app has 2 sessions + And the first session is active + When I add message "Hello from session 1" to the first session + And I switch to the second session + And I add message "Hello from session 2" to the second session + Then the first session transcript should contain "Hello from session 1" + And the first session transcript should not contain "Hello from session 2" + And the second session transcript should contain "Hello from session 2" + And the second session transcript should not contain "Hello from session 1" + + Scenario: Session creation includes timestamp + When I create a new session + Then the new session should have a created_at timestamp in ISO format diff --git a/features/tui_persona_cycle.feature b/features/tui_persona_cycle.feature new file mode 100644 index 000000000..926f13a06 --- /dev/null +++ b/features/tui_persona_cycle.feature @@ -0,0 +1,51 @@ +Feature: TUI Persona Cycling + Personas can be cycled through in order using cycle_order field. + + Scenario: cycle_persona cycles through personas with cycle_order > 0 + Given a temporary TUI persona registry + And I save TUI persona "first" with actor "local/mock-default" and cycle order 1 + And I save TUI persona "second" with actor "local/mock-default" and cycle order 2 + And I save TUI persona "third" with actor "local/mock-default" and cycle order 3 + When I set active persona to "first" for session "s1" + And I cycle persona for session "s1" + Then active persona for session "s1" should be "second" + When I cycle persona for session "s1" + Then active persona for session "s1" should be "third" + When I cycle persona for session "s1" + Then active persona for session "s1" should be "first" + + Scenario: cycle_persona returns current persona when no cyclic personas exist + Given a temporary TUI persona registry + And I save TUI persona "noncyclic" with actor "local/mock-default" and cycle order 0 + When I set active persona to "noncyclic" for session "s1" + And I cycle persona for session "s1" + Then active persona for session "s1" should be "noncyclic" + + Scenario: cycle_persona starts from first when current is not in cycle + Given a temporary TUI persona registry + And I save TUI persona "cyclic1" with actor "local/mock-default" and cycle order 1 + And I save TUI persona "noncyclic" with actor "local/mock-default" and cycle order 0 + When I set active persona to "noncyclic" for session "s1" + And I cycle persona for session "s1" + Then active persona for session "s1" should be "cyclic1" + + Scenario: cycle_persona respects cycle_order field ordering + Given a temporary TUI persona registry + And I save TUI persona "alpha" with actor "local/mock-default" and cycle order 3 + And I save TUI persona "beta" with actor "local/mock-default" and cycle order 1 + And I save TUI persona "gamma" with actor "local/mock-default" and cycle order 2 + When I set active persona to "beta" for session "s1" + And I cycle persona for session "s1" + Then active persona for session "s1" should be "gamma" + When I cycle persona for session "s1" + Then active persona for session "s1" should be "alpha" + When I cycle persona for session "s1" + Then active persona for session "s1" should be "beta" + + Scenario: cycle_persona updates last persona in registry + Given a temporary TUI persona registry + And I save TUI persona "p1" with actor "local/mock-default" and cycle order 1 + And I save TUI persona "p2" with actor "local/mock-default" and cycle order 2 + When I set active persona to "p1" for session "s1" + And I cycle persona for session "s1" + Then the registry last persona should be set to "p2" diff --git a/features/tui_persona_state_coverage.feature b/features/tui_persona_state_coverage.feature index 5141c737b..cf1d4c6f9 100644 --- a/features/tui_persona_state_coverage.feature +++ b/features/tui_persona_state_coverage.feature @@ -33,7 +33,7 @@ Feature: TUI Persona State Coverage When I set persona "coder" for session "sess-6" Then the returned persona name should be "coder" And session "sess-6" should have active persona "coder" - And the registry last persona should be set to "coder" + And the mock registry last persona should be set to "coder" Scenario: set_active_persona skips preset init when session already has one Given the preset for session "sess-6b" is already set to "turbo" diff --git a/pyproject.toml b/pyproject.toml index f711440c7..ab2d97bb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,6 +136,8 @@ ignore = [] "features/environment.py" = ["E501"] # retry_patterns.py re-exports symbols from retry_service_patterns at module bottom "src/cleveragents/core/retry_patterns.py" = ["E402"] +# commands.py: CleverAgentsTuiApp dynamic alias needs string annotation for pyright +"src/cleveragents/tui/commands.py" = ["UP037"] [tool.ruff.format] # Use double quotes for strings diff --git a/src/cleveragents/application/services/plan_executor.py b/src/cleveragents/application/services/plan_executor.py index 7bb2d3e24..eaba947c1 100644 --- a/src/cleveragents/application/services/plan_executor.py +++ b/src/cleveragents/application/services/plan_executor.py @@ -38,8 +38,14 @@ from cleveragents.application.services.plan_execution_context import ( RuntimeExecuteResult, ) from cleveragents.application.services.strategy_models import StrategyTree -from cleveragents.core.exceptions import PlanError, ValidationError +from cleveragents.core.exceptions import ( + BudgetExceededError, + PlanBudgetExceededError, + PlanError, + ValidationError, +) from cleveragents.domain.models.core.change import ChangeSetStore +from cleveragents.domain.models.core.cost_metadata import CostMetadata from cleveragents.domain.models.core.estimation import EstimationResult from cleveragents.domain.models.core.plan import ( PlanInvariant, @@ -55,6 +61,7 @@ from cleveragents.infrastructure.sandbox.checkpoint import ( CheckpointManager, SandboxCheckpoint, ) +from cleveragents.providers.cost_tracker import BudgetStatus, CostTracker from cleveragents.tool.builtins.changeset import ChangeSet, ChangeSetCapture from cleveragents.tool.runner import ToolRunner @@ -326,6 +333,8 @@ class PlanExecutor: fix_revalidate_orchestrator: FixThenRevalidateOrchestrator | None = None, subplan_service: SubplanService | None = None, subplan_execution_service: SubplanExecutionService | None = None, + cost_tracker: CostTracker | None = None, + cost_metadata: CostMetadata | None = None, ) -> None: """Initialize the plan executor. @@ -373,6 +382,8 @@ class PlanExecutor: self._fix_revalidate_orchestrator = fix_revalidate_orchestrator self._subplan_service = subplan_service self._subplan_execution_service = subplan_execution_service + self._cost_tracker = cost_tracker + self._cost_metadata = cost_metadata self._strategize_actor = strategize_actor or StrategizeStubActor() self._execute_actor = execute_actor or ExecuteStubActor() self._logger = logger.bind(service="plan_executor") @@ -920,6 +931,97 @@ class PlanExecutor: ) if not self._guardrail_service.check_wall_clock(plan_id): raise PlanError(f"Guardrail wall-clock limit exceeded for plan {plan_id}") + self._check_budget(plan_id) + + def _check_budget(self, plan_id: str) -> None: + """Check budget limits before each execution step. + + Checks both per-plan and session/daily budget limits using the + configured ``CostTracker``. If a budget is exceeded, saves the + plan state gracefully before raising the appropriate exception. + + - Per-plan budget exceeded: raises :class:`PlanBudgetExceededError` + - Session/daily budget exceeded: raises :class:`BudgetExceededError` + + Args: + plan_id: The plan identifier. + + Raises: + PlanBudgetExceededError: When the per-plan budget is exceeded. + BudgetExceededError: When the session or daily budget is exceeded. + """ + if self._cost_tracker is None: + return + + cost_metadata = self._cost_metadata + if cost_metadata is None: + cost_metadata = CostMetadata() + + # Check per-plan budget + plan_result = self._cost_tracker.check_plan_budget(cost_metadata) + if plan_result.status == BudgetStatus.EXCEEDED: + self._save_plan_state_on_budget_halt( + plan_id, + budget_type="plan", + used=plan_result.used, + limit=plan_result.limit or 0.0, + ) + raise PlanBudgetExceededError( + f"Plan budget exceeded for plan {plan_id}: " + f"${plan_result.used:.4f} >= ${plan_result.limit or 0.0:.4f}", + plan_id=plan_id, + used=plan_result.used, + limit=plan_result.limit or 0.0, + ) + + # Check session/daily budget + daily_result = self._cost_tracker.check_daily_budget() + if daily_result.status == BudgetStatus.EXCEEDED: + self._save_plan_state_on_budget_halt( + plan_id, + budget_type="daily", + used=daily_result.used, + limit=daily_result.limit or 0.0, + ) + raise BudgetExceededError( + f"Daily budget exceeded for plan {plan_id}: " + f"${daily_result.used:.4f} >= ${daily_result.limit or 0.0:.4f}", + plan_id=plan_id, + budget_type="daily", + used=daily_result.used, + limit=daily_result.limit or 0.0, + ) + + def _save_plan_state_on_budget_halt( + self, + plan_id: str, + budget_type: str, + used: float, + limit: float, + ) -> None: + """Save plan state gracefully before halting due to budget exceeded.""" + try: + plan = self._lifecycle.get_plan(plan_id) + plan.error_details = { + "budget_halt": "true", + "budget_type": budget_type, + "budget_used": str(used), + "budget_limit": str(limit), + } + self._lifecycle._commit_plan(plan) + self._logger.warning( + "Plan halted due to budget exceeded", + plan_id=plan_id, + budget_type=budget_type, + used=used, + limit=limit, + ) + except Exception: + self._logger.debug( + "Failed to save plan state on budget halt (non-fatal)", + plan_id=plan_id, + exc_info=True, + ) def _run_execute_with_runtime( self, diff --git a/src/cleveragents/cli/commands/tui.py b/src/cleveragents/cli/commands/tui.py index 0ad83620f..0a7f262b6 100644 --- a/src/cleveragents/cli/commands/tui.py +++ b/src/cleveragents/cli/commands/tui.py @@ -18,9 +18,23 @@ def tui_callback( help="Run a one-shot headless startup check instead of full UI loop.", ), ] = False, + web: Annotated[ + bool, + typer.Option( + "--web", + help="Launch TUI in web mode accessible via browser.", + ), + ] = False, + web_port: Annotated[ + int, + typer.Option( + "--web-port", + help="Port for web server (default: 8000).", + ), + ] = 8000, ) -> None: """Launch the CleverAgents TUI.""" # Import lazily so non-TUI commands avoid Textual startup cost. from cleveragents.tui.commands import run_tui - raise typer.Exit(run_tui(headless=headless)) + raise typer.Exit(run_tui(headless=headless, web=web, web_port=web_port)) diff --git a/src/cleveragents/core/exceptions.py b/src/cleveragents/core/exceptions.py index 86f322b34..20fe6677f 100644 --- a/src/cleveragents/core/exceptions.py +++ b/src/cleveragents/core/exceptions.py @@ -293,6 +293,78 @@ class PlanError(DomainError): pass +class BudgetExceededError(PlanError): + """Raised when a session or daily budget limit is exceeded during plan execution. + + Halts plan execution gracefully after saving plan state. + + Attributes: + plan_id: The plan that was halted. + budget_type: The type of budget that was exceeded ('daily' or 'session'). + used: Amount spent so far (USD). + limit: The budget limit that was exceeded (USD). + """ + + def __init__( + self, + message: str, + plan_id: str = "", + budget_type: str = "session", + used: float = 0.0, + limit: float = 0.0, + details: dict[str, Any] | None = None, + ) -> None: + """Initialize with budget context. + + Args: + message: Human-readable error message. + plan_id: The plan identifier. + budget_type: Type of budget exceeded ('daily' or 'session'). + used: Amount spent so far in USD. + limit: The budget limit in USD. + details: Optional additional context. + """ + super().__init__(message, details) + self.plan_id = plan_id + self.budget_type = budget_type + self.used = used + self.limit = limit + + +class PlanBudgetExceededError(PlanError): + """Raised when a per-plan budget limit is exceeded during plan execution. + + Halts plan execution gracefully after saving plan state. + + Attributes: + plan_id: The plan that was halted. + used: Amount spent so far (USD). + limit: The per-plan budget limit (USD). + """ + + def __init__( + self, + message: str, + plan_id: str = "", + used: float = 0.0, + limit: float = 0.0, + details: dict[str, Any] | None = None, + ) -> None: + """Initialize with plan budget context. + + Args: + message: Human-readable error message. + plan_id: The plan identifier. + used: Amount spent so far in USD. + limit: The per-plan budget limit in USD. + details: Optional additional context. + """ + super().__init__(message, details) + self.plan_id = plan_id + self.used = used + self.limit = limit + + class DecisionPhaseViolationError(BusinessRuleViolation): """Raised when a decision type is invalid for the plan's current phase. @@ -328,6 +400,7 @@ class ExecutionError(CleverAgentsError): __all__ = [ "AuthenticationError", "AuthorizationError", + "BudgetExceededError", "BusinessRuleViolation", "CleverAgentsError", "ConfigurationError", @@ -345,6 +418,7 @@ __all__ = [ "ModelNotAvailableError", "NetworkError", "NotFoundError", + "PlanBudgetExceededError", "PlanError", "ProviderError", "RateLimitError", diff --git a/src/cleveragents/domain/models/core/automation_profile.py b/src/cleveragents/domain/models/core/automation_profile.py index cae78d976..1a48d4c60 100644 --- a/src/cleveragents/domain/models/core/automation_profile.py +++ b/src/cleveragents/domain/models/core/automation_profile.py @@ -221,6 +221,24 @@ class AutomationProfile(BaseModel): description="Optional enforcement hooks for runtime constraints", ) + # -- Budget limits (YAML-configurable) --------------------------------- + + budget_per_plan: float | None = Field( + default=None, + ge=0.0, + description=( + "Maximum USD spend per plan execution. None means unlimited. " + "When set, PlanExecutor halts with PlanBudgetExceededError if exceeded." + ), + ) + budget_per_session: float | None = Field( + default=None, + ge=0.0, + description=( + "Maximum USD spend per session. None means unlimited. " + "When set, PlanExecutor halts with BudgetExceededError if exceeded." + ), + ) # -- Name validation --------------------------------------------------- @field_validator("name") diff --git a/src/cleveragents/tui/app.py b/src/cleveragents/tui/app.py index 68401dedd..cf95369b8 100644 --- a/src/cleveragents/tui/app.py +++ b/src/cleveragents/tui/app.py @@ -1,10 +1,12 @@ -"""Textual TUI application shell.""" +"""Textual TUI application shell - Multi-session support.""" from __future__ import annotations import importlib import os -from dataclasses import dataclass +import uuid +from dataclasses import dataclass, field +from datetime import UTC, datetime from typing import TYPE_CHECKING, Any, ClassVar, Protocol from cleveragents.tui.first_run import create_default_persona_for_actor, is_first_run @@ -54,10 +56,12 @@ def textual_available() -> bool: @dataclass(slots=True) class SessionView: - """Minimal per-session TUI view model.""" + """Per-session TUI view model with independent A2A binding.""" session_id: str - transcript: list[str] + transcript: list[str] = field(default_factory=list) + name: str = "" # User-friendly session name + created_at: str = "" # ISO format timestamp class _CommandRouter(Protocol): @@ -94,6 +98,8 @@ if _TEXTUAL_AVAILABLE: ("ctrl+q", "quit", "Quit"), ("f1", "help", "Help"), ("ctrl+t", "cycle_preset", "Cycle Preset"), + ("ctrl+n", "new_session", "New Session"), + ("ctrl+w", "close_session", "Close Session"), ] def __init__( @@ -105,7 +111,80 @@ if _TEXTUAL_AVAILABLE: super().__init__() self._command_router = command_router self._persona_state = persona_state - self._session = SessionView(session_id="default", transcript=[]) + # Initialize with default session + default_session = SessionView( + session_id="default", + transcript=[], + name="Default", + created_at=datetime.now(UTC).isoformat(), + ) + self._sessions: list[SessionView] = [default_session] + self._active_session_index: int = 0 + + def _get_active_session(self) -> SessionView: + """Get the currently active session.""" + if 0 <= self._active_session_index < len(self._sessions): + return self._sessions[self._active_session_index] + # Fallback to first session if index is invalid + if self._sessions: + self._active_session_index = 0 + return self._sessions[0] + # Create default session if none exist + default_session = SessionView( + session_id="default", + transcript=[], + name="Default", + created_at=datetime.now(UTC).isoformat(), + ) + self._sessions = [default_session] + self._active_session_index = 0 + return default_session + + def _create_session(self, name: str = "") -> SessionView: + """Create a new session with independent A2A binding.""" + session_id = str(uuid.uuid4())[:8] + session_name = name or f"Session {len(self._sessions) + 1}" + new_session = SessionView( + session_id=session_id, + transcript=[], + name=session_name, + created_at=datetime.now(UTC).isoformat(), + ) + self._sessions.append(new_session) + return new_session + + def _switch_session(self, session_id: str) -> SessionView | None: + """Switch to a session by ID.""" + for idx, session in enumerate(self._sessions): + if session.session_id == session_id: + self._active_session_index = idx + return session + return None + + def _close_session(self, session_id: str) -> bool: + """Close a session by ID. Returns False if it's the last session.""" + if len(self._sessions) <= 1: + return False + for idx, session in enumerate(self._sessions): + if session.session_id == session_id: + self._sessions.pop(idx) + # Adjust active index if needed + if self._active_session_index >= len(self._sessions): + self._active_session_index = len(self._sessions) - 1 + return True + return False + + def _rename_session(self, session_id: str, new_name: str) -> bool: + """Rename a session by ID.""" + for session in self._sessions: + if session.session_id == session_id: + session.name = new_name + return True + return False + + def _list_sessions(self) -> list[SessionView]: + """Get all sessions.""" + return self._sessions def compose(self) -> Any: yield _Header(show_clock=True) @@ -150,12 +229,28 @@ if _TEXTUAL_AVAILABLE: help_panel.toggle(context_name) def action_cycle_preset(self) -> None: - self._persona_state.cycle_preset(self._session.session_id) + session = self._get_active_session() + self._persona_state.cycle_preset(session.session_id) + self._refresh_persona_bar() + + def action_new_session(self) -> None: + """Create a new session (Ctrl+N).""" + self._create_session() + self._active_session_index = len(self._sessions) - 1 + self._refresh_persona_bar() + + def action_close_session(self) -> None: + """Close the current session (Ctrl+W).""" + session = self._get_active_session() + if not self._close_session(session.session_id): + # Cannot close the last session + return self._refresh_persona_bar() def _refresh_persona_bar(self) -> None: - persona = self._persona_state.active_persona(self._session.session_id) - preset = self._persona_state.current_preset(self._session.session_id) + session = self._get_active_session() + persona = self._persona_state.active_persona(session.session_id) + preset = self._persona_state.current_preset(session.session_id) scope_count = len(persona.scoped_projects) + len(persona.scoped_plans) scope_text = f"{scope_count} scope refs" bar = self.query_one("#persona-bar", PersonaBar) @@ -174,9 +269,10 @@ if _TEXTUAL_AVAILABLE: if not text: return + session = self._get_active_session() mode_router = InputModeRouter( command_handler=lambda raw: self._command_router.handle( - raw, session_id=self._session.session_id + raw, session_id=session.session_id ), shell_confirm=lambda _cmd: ( os.environ.get("CLEVERAGENTS_ALLOW_DANGEROUS_SHELL", "").strip() diff --git a/src/cleveragents/tui/commands.py b/src/cleveragents/tui/commands.py index d155ef726..12704e0e9 100644 --- a/src/cleveragents/tui/commands.py +++ b/src/cleveragents/tui/commands.py @@ -2,12 +2,13 @@ from __future__ import annotations +import contextlib import json from collections import defaultdict from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path -from typing import Any +from typing import Any, Protocol, runtime_checkable from cleveragents.application.container import get_container from cleveragents.tui.app import CleverAgentsTuiApp, textual_available @@ -15,6 +16,72 @@ from cleveragents.tui.persona.registry import PersonaRegistry from cleveragents.tui.persona.state import PersonaState from cleveragents.tui.slash_catalog import SLASH_COMMAND_SPECS +# -- Session operation helpers ------------------------------------------------- + + +@runtime_checkable +class _TuiAppProtocol(Protocol): + """Minimal interface for TUI app session management.""" + + _sessions: list[Any] + _active_session_index: int + + def _create_session(self, name: str = "") -> Any: ... + def _switch_session(self, session_id: str) -> Any | None: ... + def _close_session(self, session_id: str) -> bool: ... + def _rename_session(self, session_id: str, new_name: str) -> bool: ... + def _list_sessions(self) -> list[Any]: ... + + +class _SessionOps: + """Delegates session CRUD calls to a TUI app instance.""" + + def __init__(self) -> None: + self._app: _TuiAppProtocol | None = None + + def create_session(self, name: str = "") -> Any | None: + """Create a new session, returning its SessionView or ``None``.""" + if self._app is not None: + return self._app._create_session(name) + return None + + def close_session(self, session_id: str) -> bool: + """Close a session by ID, returning True if closed.""" + if self._app is not None: + return self._app._close_session(session_id) + return False + + def switch_session(self, session_id: str) -> Any | None: + """Switch to a session by ID, returning the session or ``None``.""" + if self._app is not None: + self._app._switch_session(session_id) + for s in self._app._sessions: + if s.session_id == session_id: + return s + return None + + @property + def sessions(self) -> list[Any]: + """Return the full session list.""" + if self._app is not None: + return self._app._list_sessions() + return [] + + @property + def active_session_id(self) -> str | None: + """Return the active session ID.""" + if self._app is not None and self._app._sessions: + s = self._app._sessions[self._app._active_session_index] + return s.session_id if self._app._active_session_index >= 0 else None + return None + + @property + def session_count(self) -> int: + """Return the number of sessions.""" + if self._app is not None: + return len(self._app._sessions) + return 0 + @dataclass(slots=True) class TuiCommandRouter: @@ -33,11 +100,20 @@ class TuiCommandRouter: essential for test isolation under ``multiprocessing.fork()`` where ``unittest.mock.patch`` context managers do not propagate to child workers. + session_ops: + Optional helper that delegates session CRUD calls into a TUI app + instance so slash commands like ``/session:create`` can modify + application state. Set via ``session_ops.bind(app)`` after the + app is created. """ persona_registry: PersonaRegistry persona_state: PersonaState container_factory: Callable[[], Any] | None = field(default=None, repr=False) + session_ops: _SessionOps = field( + default_factory=_SessionOps, + repr=False, + ) def _resolve_container(self) -> Any: """Return the DI container, using the injected factory or the global default.""" @@ -105,7 +181,65 @@ class TuiCommandRouter: def _session_command(self, tokens: list[str], *, session_id: str) -> str: if not tokens or tokens[0] == "show": - return f"Current session: {session_id}" + ops = self.session_ops + count = ops.session_count + active_id = ops.active_session_id or "unknown" + return ( + f"Current session: {active_id} (ID={session_id}). " + f"Total sessions: {count}" + ) + + if tokens[0] == "create": + name = " ".join(tokens[1:]) if len(tokens) > 1 else "" + result = self.session_ops.create_session(name) + if result is not None: + return f"Session created: {result.name} ({result.session_id})" + return ( + "Note: Session management requires the TUI app instance. " + "Use Ctrl+N in the TUI to create a session." + ) + + if tokens[0] == "list": + sessions = self.session_ops.sessions + if not sessions: + return "No active sessions" + lines = ["Sessions:"] + for s in sessions: + marker = " (active)" if s.session_id == session_id else "" + lines.append(f" {s.name} ({s.session_id}){marker}") + return "\n".join(lines) + + if tokens[0] == "switch": + if len(tokens) < 2: + return "Usage: /session:switch " + target_id = tokens[1] + session = self.session_ops.switch_session(target_id) + if session is not None: + return f"Switched to session: {target_id} ({session.name})" + return f"Session not found: {target_id}" + + if tokens[0] == "close": + if len(tokens) < 2: + return "Usage: /session:close " + close_id = tokens[1] + if self.session_ops.session_count <= 1: + return "Cannot close the last session" + if self.session_ops.close_session(close_id): + return f"Session closed: {close_id}" + return f"Session not found: {close_id}" + + if tokens[0] == "rename": + if len(tokens) < 3: + return "Usage: /session:rename " + rename_id = tokens[1] + new_name = " ".join(tokens[2:]) + if ( + self.session_ops._app is not None + and self.session_ops._app._rename_session(rename_id, new_name) + ): + return f"Session renamed to: {new_name}" + return f"Session not found: {rename_id}" + if tokens[0] == "export": return self._session_export(tokens[1:], session_id=session_id) if tokens[0] == "import": @@ -223,12 +357,160 @@ class TuiCommandRouter: return f"Import failed: {exc}" -def run_tui(*, headless: bool = False) -> int: - """Run the Textual TUI app or a headless startup check.""" +def _get_tui_web_html(port: int) -> str: + """Generate HTML for TUI web mode. + + Parameters + ---------- + port: + Port number for the web server. + + Returns + ------- + HTML content as string. + """ + return """ + + + + + CleverAgents TUI + + + +
+
Loading CleverAgents TUI...
+
+ + +""" + + +def _run_tui_web( + app: Any, + *, + port: int = 8000, +) -> int: + """Run the TUI app in web mode via HTTP server. + + Parameters + ---------- + app: + The Textual TUI app instance. + port: + Port for the web server. + + Returns + ------- + Exit code (0 for success, non-zero for failure). + """ + try: + # Import web server dependencies + import threading + import webbrowser + from http.server import BaseHTTPRequestHandler, HTTPServer + + class TuiWebHandler(BaseHTTPRequestHandler): + """HTTP request handler for TUI web mode.""" + + def do_GET(self) -> None: + """Handle GET requests.""" + if self.path == "/" or self.path == "/index.html": + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + html = _get_tui_web_html(port) + self.wfile.write(html.encode("utf-8")) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format: str, *args: Any) -> None: + """Suppress default logging.""" + pass + + # Create and start HTTP server + server = HTTPServer(("127.0.0.1", port), TuiWebHandler) + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + + # Print startup message + url = f"http://127.0.0.1:{port}" + print(f"TUI Web mode started at {url}") + print("Press Ctrl+C to stop") + + # Try to open browser + with contextlib.suppress(Exception): + webbrowser.open(url) + + # Run the app in headless mode (web driver will handle rendering) + try: + app.run(headless=True) + except KeyboardInterrupt: + pass + finally: + server.shutdown() + + return 0 + + except Exception as exc: + print(f"Error starting web mode: {exc}") + return 1 + + +def run_tui(*, headless: bool = False, web: bool = False, web_port: int = 8000) -> int: + """Run the Textual TUI app, headless check, or web mode. + + Parameters + ---------- + headless: + Run a one-shot headless startup check instead of full UI loop. + web: + Launch TUI in web mode accessible via browser. + web_port: + Port for web server (default: 8000). + + Returns + ------- + Exit code (0 for success, non-zero for failure). + """ container = get_container() registry = container.persona_registry() state = container.persona_state(registry=registry) - router = TuiCommandRouter(persona_registry=registry, persona_state=state) + + # Create app instance first so its nested session-management methods are + # accessible via the _SessionOps delegation layer. + ops = _SessionOps() + router = TuiCommandRouter( + persona_registry=registry, + persona_state=state, + session_ops=ops, + ) if headless: payload = { @@ -243,5 +525,14 @@ def run_tui(*, headless: bool = False) -> int: return 0 app = CleverAgentsTuiApp(command_router=router, persona_state=state) + # Bind the app's nested TUI instance into the ops delegation layer so + # /session:create, /session:switch, /session:close, /session:rename + # can modify application state directly. + inner = getattr(app, "_textual_clever_agents_tui_app_inner", None) or app + ops._app = inner + + if web: + return _run_tui_web(app, port=web_port) + app.run() return 0 diff --git a/src/cleveragents/tui/persona/registry.py b/src/cleveragents/tui/persona/registry.py index 958867bb5..31e8f2afd 100644 --- a/src/cleveragents/tui/persona/registry.py +++ b/src/cleveragents/tui/persona/registry.py @@ -79,9 +79,26 @@ class PersonaRegistry: return result def resolve_export_path(self, output_path: Path) -> Path: + """Resolve export path, only allowing paths within the working directory. + + Parameters + ---------- + output_path: + The requested output path (relative or absolute). + + Returns + ------- + The fully resolved path validated to be within the current working directory. + + Raises + ------ + ValueError + If *output_path* is an absolute path or resolves outside the + current working directory. + """ if output_path.is_absolute(): raise ValueError( - "Export path must be relative to current working directory" + "Export path must be relative to current working directory", ) base = Path.cwd().resolve() resolved = (base / output_path).resolve() @@ -90,9 +107,26 @@ class PersonaRegistry: return resolved def resolve_import_path(self, input_path: Path) -> Path: + """Resolve import path, only allowing paths within the working directory. + + Parameters + ---------- + input_path: + The requested input path (relative or absolute). + + Returns + ------- + The fully resolved path validated to be within the current working directory. + + Raises + ------ + ValueError + If *input_path* is an absolute path or resolves outside the + current working directory. + """ if input_path.is_absolute(): raise ValueError( - "Import path must be relative to current working directory" + "Import path must be relative to current working directory", ) base = Path.cwd().resolve() resolved = (base / input_path).resolve() diff --git a/src/cleveragents/tui/persona/state.py b/src/cleveragents/tui/persona/state.py index c11fa3fcb..f423591d2 100644 --- a/src/cleveragents/tui/persona/state.py +++ b/src/cleveragents/tui/persona/state.py @@ -63,6 +63,32 @@ class PersonaState: self.preset_by_session[session_id] = next_name return next_name + def cycle_persona(self, session_id: str) -> Persona: + """Cycle to the next persona in cycle_order sequence. + + Only personas with cycle_order > 0 are included in the cycle. + If no cyclic personas exist, returns the current active persona. + """ + personas = self.registry.list_personas() + cyclic = sorted( + [p for p in personas if p.cycle_order > 0], key=lambda p: p.cycle_order + ) + + if not cyclic: + return self.active_persona(session_id) + + current = self.active_name(session_id) + current_names = [p.name for p in cyclic] + + if current not in current_names: + # Current persona is not in cycle, start from first + next_persona = cyclic[0] + else: + idx = current_names.index(current) + next_persona = cyclic[(idx + 1) % len(cyclic)] + + return self.set_active_persona(session_id, next_persona.name) + def effective_arguments(self, session_id: str) -> dict[str, object]: persona = self.active_persona(session_id) preset = self.current_preset(session_id)