Compare commits

..

2 Commits

Author SHA1 Message Date
HAL9000 806c1f34f1 style: fix code formatting with ruff
Fix formatting issues in OpenRouter provider registry implementation to pass CI lint checks.
2026-04-19 06:11:57 +00:00
HAL9000 c228ad546e feat(providers): implement OpenRouter provider support in ProviderRegistry
CI / lint (pull_request) Failing after 56s
CI / helm (pull_request) Successful in 51s
CI / push-validation (pull_request) Successful in 22s
CI / quality (pull_request) Successful in 4m36s
CI / typecheck (pull_request) Successful in 4m38s
CI / security (pull_request) Successful in 5m11s
CI / coverage (pull_request) Has been skipped
CI / build (pull_request) Successful in 3m47s
CI / integration_tests (pull_request) Successful in 7m36s
CI / e2e_tests (pull_request) Successful in 7m4s
CI / unit_tests (pull_request) Successful in 8m42s
CI / docker (pull_request) Has been skipped
CI / status-check (pull_request) Failing after 3s
Added handling for ProviderType.OPENROUTER in _create_provider_llm in src/cleveragents/providers/registry.py to resolve ValueError: Unsupported provider type when using OpenRouter via create_llm.

Created features/openrouter_provider_registry.feature with 11 scenarios validating OpenRouter provider behavior in ProviderRegistry.

Created features/steps/openrouter_provider_registry_steps.py with step definitions for the new feature file.

Created docs/reference/providers.md with comprehensive documentation including the OpenRouter configuration guide.

ISSUES CLOSED: #8907
2026-04-19 02:44:16 +00:00
34 changed files with 6138 additions and 4775 deletions
-15
View File
@@ -28,14 +28,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
correctly in all deployment modes: Docker containers, local pip installs
(wheel or editable), and development environments.
- **TDD Non-AssertionError Guard Visibility** (#8294): `apply_tdd_inversion` in
- **bug-hunt-pool-supervisor Non-Blocking Tracking** (#8835): The automation-tracking-manager
call in step 5 was blocking the main loop indefinitely, causing 3+ consecutive initialization
failures. Step 5 now explicitly marks tracking as best-effort -- if the call does not complete
within a reasonable time or fails, it is skipped and the supervisor continues to the next
cycle. A new Rule 9 reinforces that tracking must never block the main loop; core
functionality (module mapping, worker dispatch, monitoring) takes priority over status
reporting.
`features/environment.py` now emits its non-assertion exception guard warning to
both the structured logger and `stderr` via a new `_warning_with_stderr` helper.
This makes the guard firing visible in standard Behave console output and CI log
@@ -356,13 +348,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
references; persisted in `~/.config/cleveragents/personas/`.
- **TUI session export/import** — full JSON round-trip and Markdown transcript export
(`--format md`).
- **PersonaRegistry** — YAML-based persona management system with full CRUD operations,
atomic file operations with fcntl locking, and thread-safe implementation.
- **TUI Web Mode** (`--web`, `--web-port`) — HTTP server for browser-based TUI access
with HTML interface and WebSocket support placeholder for future enhancements.
- **Multi-Session Tabs** — Enhanced TUI with independent session management, session
creation/switching/closing/renaming via keyboard bindings (Ctrl+N for new, Ctrl+W for close),
and independent A2A binding support per session.
---
-288
View File
@@ -1,288 +0,0 @@
# Agent Development Guide
This comprehensive guide covers the architecture, lifecycle, configuration, and best practices for developing agents in CleverAgents.
## Table of Contents
1. [Agent Architecture Overview](#agent-architecture-overview)
2. [Agent Lifecycle](#agent-lifecycle)
3. [Agent Configuration](#agent-configuration)
4. [Skill Integration and Management](#skill-integration-and-management)
5. [Tool Integration](#tool-integration)
6. [Resource Management](#resource-management)
7. [Error Handling and Recovery](#error-handling-and-recovery)
8. [Agent Communication Patterns](#agent-communication-patterns)
9. [Testing Agents](#testing-agents)
10. [Performance Optimization](#performance-optimization)
11. [Real-world Examples](#real-world-examples)
## Agent Architecture Overview
An **Agent** in CleverAgents is an autonomous entity that can perceive, reason, act, and learn.
### Agent Components
- **Agent State & Configuration** - Identity, goals, and configuration parameters
- **Reasoning Engine** - LLM integration, planning, and decision-making
- **Execution Layer** - Skill execution, tool invocation, resource management
- **Integration Points** - Skills, tools, resources, and providers
### Agent Types
CleverAgents supports several agent patterns:
1. **Task-Specific Agents** - Focused on a single domain or task
2. **Orchestrator Agents** - Coordinate multiple sub-agents
3. **Reactive Agents** - Respond to events with minimal planning
4. **Deliberative Agents** - Perform extensive planning before execution
## Agent Lifecycle
The agent lifecycle consists of several key phases:
1. **Created** - Agent instance instantiated
2. **Initialized** - Configuration loaded, skills registered
3. **Ready** - Agent prepared for execution
4. **Executing** - Processing tasks, invoking skills
5. **Paused** - (Optional) Suspended execution
6. **Completed** - Task finished successfully
7. **Cleaned Up** - Resources released
### Lifecycle Hooks
Agents support hooks at various lifecycle points:
- `on_initialize()` - Called after initialization
- `on_execution_start()` - Called when execution begins
- `on_execution_end(result)` - Called when execution completes
- `on_error(error)` - Called when an error occurs
- `on_cleanup()` - Called during cleanup
## Agent Configuration
Agents are configured through a hierarchical configuration system supporting:
- **Core settings** - max_iterations, timeout, temperature
- **Model configuration** - provider, model name, API keys
- **Skills configuration** - enabled skills and their settings
- **Tool configuration** - available tools and their settings
- **Resource limits** - memory, CPU, timeout constraints
- **Safety settings** - guardrails, tool call limits, allowed domains
## Skill Integration and Management
**Skills** are reusable, domain-specific capabilities that agents can invoke.
### Skill Structure
Skills should:
- Have a clear name and description
- Define input parameters and return types
- Implement error handling
- Support configuration
- Be testable in isolation
### Using Skills in Agents
Skills are executed through the agent:
```python
result = agent.execute_skill(
skill_name="document_analysis",
parameters={"document": content, "analysis_type": "full"}
)
```
## Tool Integration
**Tools** are external integrations that agents can invoke to interact with systems, APIs, and services.
### Tool Definition
Tools should:
- Have clear input/output specifications
- Implement error handling
- Support timeout and retry logic
- Be idempotent when possible
- Log execution details
### Using Tools in Agents
Tools are executed through the agent:
```python
result = agent.execute_tool(
tool_name="file_reader",
parameters={"file_path": "/path/to/file.txt"}
)
```
## Resource Management
Agents manage various types of resources:
- **Memory** - Agent state, context, and intermediate results
- **Compute** - CPU time and processing capacity
- **External** - API calls, database connections, file handles
- **Time** - Execution timeouts and deadlines
## Error Handling and Recovery
### Error Types
- **SkillExecutionError** - Skill execution failed
- **ToolExecutionError** - Tool execution failed
- **ResourceExhaustedError** - Resource limits exceeded
- **TimeoutError** - Execution timeout
- **ValidationError** - Input validation failed
### Error Handling Strategies
1. **Try-Catch with Recovery** - Implement retry logic with exponential backoff
2. **Fallback Strategies** - Provide fallback goals or alternative execution paths
3. **Graceful Degradation** - Reduce feature set or use cached results
## Agent Communication Patterns
### Agent-to-Agent Communication
Agents can communicate through:
- Message buses for asynchronous communication
- Direct method calls for synchronous communication
- Event systems for event-driven communication
### Hierarchical Agent Communication
Orchestrator agents can:
- Delegate tasks to sub-agents
- Coordinate multiple sub-agents
- Aggregate results from sub-agents
- Handle failures in sub-agents
## Testing Agents
### Unit Testing
Test individual components:
- Agent initialization
- Skill execution
- Tool execution
- Error handling
### Integration Testing
Test component interactions:
- Skill and tool integration
- Agent and skill integration
- Error recovery in integrated systems
### Performance Testing
Test performance characteristics:
- Execution speed
- Memory usage
- Resource utilization
- Scalability
## Performance Optimization
### Caching Strategies
Implement caching to:
- Avoid redundant skill executions
- Reduce external API calls
- Improve response times
### Parallel Execution
Execute multiple goals in parallel:
- Use thread pools for I/O-bound operations
- Use process pools for CPU-bound operations
- Aggregate results from parallel executions
### Lazy Loading
Load resources on demand:
- Lazy load skills
- Lazy load tools
- Lazy load models
## Real-world Examples
### Example 1: Document Analysis Agent
Analyzes documents and generates insights:
- Extracts text from documents
- Analyzes sentiment
- Extracts entities
- Generates summaries
### Example 2: Project Management Agent
Manages projects and coordinates tasks:
- Plans projects
- Allocates resources
- Tracks progress
- Manages risks
### Example 3: Customer Support Agent
Provides customer support and issue resolution:
- Classifies issues
- Searches knowledge base
- Generates solutions
- Escalates when necessary
## Best Practices
### 1. **Design for Composability**
- Create agents with clear, single responsibilities
- Design skills and tools to be reusable
- Use composition over inheritance
### 2. **Implement Robust Error Handling**
- Handle all expected error types
- Implement recovery strategies
- Log errors comprehensively
### 3. **Manage Resources Carefully**
- Set appropriate resource limits
- Monitor resource usage
- Clean up resources properly
### 4. **Test Thoroughly**
- Write unit tests for skills and tools
- Test agent integration
- Perform performance testing
### 5. **Monitor and Observe**
- Log agent execution
- Track performance metrics
- Monitor resource usage
### 6. **Document Your Agents**
- Document agent purpose and capabilities
- Document skill and tool APIs
- Provide usage examples
## Conclusion
The CleverAgents framework provides a comprehensive platform for building intelligent, autonomous agents. By following the patterns and practices outlined in this guide, you can create robust, scalable agents that effectively accomplish complex tasks.
For more information, see:
- [Agent API Reference](../reference/agent_api_reference.md)
- [Skill Development Guide](./skill_development.md)
- [Tool Integration Guide](./tool_integration.md)
-328
View File
@@ -1,328 +0,0 @@
# Installation and Setup Guide
This guide provides comprehensive instructions for installing and setting up CleverAgents in your development environment.
## Prerequisites
Before you begin, ensure you have the following installed on your system:
### System Requirements
- **Operating System**: Linux, macOS, or Windows (with WSL2)
- **Python**: Version 3.10 or higher
- **Git**: Version 2.30 or higher
- **Memory**: Minimum 4GB RAM (8GB recommended)
- **Disk Space**: At least 2GB free space
### Required Tools
- **pip**: Python package manager (usually comes with Python)
- **virtualenv** or **venv**: For creating isolated Python environments
- **Docker** (optional): For containerized deployments
- **Docker Compose** (optional): For multi-container setups
### Development Tools (Optional but Recommended)
- **Visual Studio Code** or your preferred IDE
- **Git GUI client** (e.g., GitKraken, SourceTree)
- **Make**: For running build commands
- **nox**: For test automation
## Step-by-Step Installation
### 1. Clone the Repository
```bash
git clone https://github.com/cleverthis/cleveragents-core.git
cd cleveragents-core
```
### 2. Create a Virtual Environment
Using Python's built-in venv:
```bash
python3 -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
Or using virtualenv:
```bash
virtualenv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
### 3. Upgrade pip and Install Build Tools
```bash
pip install --upgrade pip setuptools wheel
```
### 4. Install CleverAgents
#### Option A: Development Installation (Recommended for Contributors)
```bash
pip install -e ".[dev]"
```
This installs CleverAgents in editable mode with all development dependencies.
#### Option B: Standard Installation
```bash
pip install .
```
#### Option C: Installation with Optional Dependencies
```bash
# With all optional dependencies
pip install -e ".[all]"
# With specific extras
pip install -e ".[docs,test,dev]"
```
### 5. Verify Installation
```bash
cleveragents --version
cleveragents --help
```
## Configuration
### Environment Variables
Create a `.env` file in the project root:
```bash
# API Configuration
CLEVERAGENTS_API_HOST=localhost
CLEVERAGENTS_API_PORT=8000
# Logging
CLEVERAGENTS_LOG_LEVEL=INFO
# Database
CLEVERAGENTS_DB_URL=sqlite:///./cleveragents.db
# Optional: AI Provider Configuration
OPENAI_API_KEY=your_api_key_here
```
### Configuration File
Create a `config.yaml` in your project directory:
```yaml
cleveragents:
version: 1
logging:
level: INFO
format: json
database:
type: sqlite
path: ./cleveragents.db
api:
host: localhost
port: 8000
debug: false
```
## Verification Steps
### 1. Check Installation
```bash
python -c "import cleveragents; print(cleveragents.__version__)"
```
### 2. Run Basic Tests
```bash
pytest tests/ -v --tb=short
```
### 3. Start the Development Server
```bash
cleveragents server --host 0.0.0.0 --port 8000
```
### 4. Verify API Endpoint
```bash
curl http://localhost:8000/health
```
Expected response:
```json
{
"status": "healthy",
"version": "x.y.z"
}
```
## Common Issues and Troubleshooting
### Issue 1: Python Version Mismatch
**Error**: `Python 3.10 or higher is required`
**Solution**:
```bash
python3 --version
# If version is < 3.10, install a newer version
# macOS: brew install python@3.11
# Ubuntu: sudo apt-get install python3.11
```
### Issue 2: Virtual Environment Not Activated
**Error**: `command not found: cleveragents`
**Solution**:
```bash
# Ensure virtual environment is activated
source venv/bin/activate # Linux/macOS
# or
venv\Scripts\activate # Windows
```
### Issue 3: Permission Denied on Linux/macOS
**Error**: `Permission denied: './venv/bin/activate'`
**Solution**:
```bash
chmod +x venv/bin/activate
source venv/bin/activate
```
### Issue 4: Dependency Conflicts
**Error**: `ERROR: pip's dependency resolver does not currently take into account all the packages`
**Solution**:
```bash
# Clear pip cache and reinstall
pip cache purge
pip install --upgrade --force-reinstall -e ".[dev]"
```
### Issue 5: Database Connection Error
**Error**: `sqlite3.OperationalError: unable to open database file`
**Solution**:
```bash
# Ensure database directory exists
mkdir -p data
# Update CLEVERAGENTS_DB_URL in .env
CLEVERAGENTS_DB_URL=sqlite:///./data/cleveragents.db
```
### Issue 6: Port Already in Use
**Error**: `Address already in use: ('0.0.0.0', 8000)`
**Solution**:
```bash
# Use a different port
cleveragents server --port 8001
# Or kill the process using port 8000
lsof -i :8000 # Find process ID
kill -9 <PID> # Kill the process
```
## Next Steps
After successful installation and verification:
1. **Read the Documentation**: Start with the [Architecture Guide](../architecture.md)
2. **Explore Examples**: Check the `examples/` directory for sample projects
3. **Run Tests**: Execute the full test suite with `pytest`
4. **Set Up IDE**: Configure your IDE with Python linting and formatting tools
5. **Join the Community**: Visit our [GitHub Discussions](https://github.com/cleverthis/cleveragents-core/discussions)
## Development Workflow
### Running Tests
```bash
# Run all tests
pytest
# Run specific test file
pytest tests/test_core.py
# Run with coverage
pytest --cov=src tests/
# Run with verbose output
pytest -v
```
### Code Quality Checks
```bash
# Format code
black src/ tests/
# Lint code
ruff check src/ tests/
# Type checking
mypy src/
# All checks with nox
nox
```
### Building Documentation
```bash
# Install documentation dependencies
pip install -e ".[docs]"
# Build documentation
mkdocs build
# Serve documentation locally
mkdocs serve
```
## Uninstallation
To remove CleverAgents:
```bash
# Deactivate virtual environment
deactivate
# Remove virtual environment
rm -rf venv
# Or if using virtualenv
virtualenv --clear venv
```
## Getting Help
- **Documentation**: https://docs.cleverthis.com/cleveragents
- **GitHub Issues**: https://github.com/cleverthis/cleveragents-core/issues
- **GitHub Discussions**: https://github.com/cleverthis/cleveragents-core/discussions
- **Email Support**: support@cleverthis.com
## Additional Resources
- [Architecture Guide](../architecture.md)
- [Development Guide](../development/agent-system-specification.md)
- [API Reference](../api/index.md)
- [FAQ](../faq.md)
File diff suppressed because it is too large Load Diff
-481
View File
@@ -1,481 +0,0 @@
# Agent API Reference
This reference document provides detailed API documentation for the Agent class and related components in CleverAgents.
## Table of Contents
1. [Agent Class API](#agent-class-api)
2. [Agent Methods and Properties](#agent-methods-and-properties)
3. [Configuration Options](#configuration-options)
4. [Lifecycle Hooks](#lifecycle-hooks)
5. [Error Codes and Exceptions](#error-codes-and-exceptions)
## Agent Class API
### Class Definition
```python
class Agent:
"""
Base class for all agents in CleverAgents.
An agent is an autonomous entity that can perceive its environment,
reason about goals and available actions, and execute plans.
"""
```
### Constructor
```python
def __init__(
self,
name: str,
description: str = "",
version: str = "1.0.0",
config: Optional[Dict[str, Any]] = None
) -> None:
"""
Initialize an agent.
Args:
name: Unique identifier for the agent
description: Human-readable description of the agent
version: Version string for the agent
config: Optional configuration dictionary
"""
```
## Agent Methods and Properties
### Core Methods
#### execute()
```python
def execute(
self,
goal: str,
context: Optional[Dict[str, Any]] = None,
constraints: Optional[Dict[str, Any]] = None,
timeout: Optional[int] = None
) -> Dict[str, Any]:
"""
Execute the agent on a goal.
Args:
goal: The goal to accomplish
context: Optional context information
constraints: Optional execution constraints
timeout: Optional timeout in seconds
Returns:
Execution result dictionary
Raises:
AgentError: If execution fails
TimeoutError: If execution exceeds timeout
"""
```
#### initialize()
```python
def initialize(
self,
config: Dict[str, Any]
) -> None:
"""
Initialize the agent with configuration.
Args:
config: Configuration dictionary
Raises:
ConfigurationError: If configuration is invalid
"""
```
#### cleanup()
```python
def cleanup() -> None:
"""
Clean up agent resources.
Should be called when the agent is no longer needed.
"""
```
### Skill Methods
#### add_skill()
```python
def add_skill(
self,
skill: Skill
) -> None:
"""
Add a skill to the agent.
Args:
skill: Skill instance to add
Raises:
ValueError: If skill name already exists
"""
```
#### execute_skill()
```python
def execute_skill(
self,
skill_name: str,
parameters: Dict[str, Any]
) -> Dict[str, Any]:
"""
Execute a skill.
Args:
skill_name: Name of the skill to execute
parameters: Parameters for the skill
Returns:
Skill execution result
Raises:
SkillNotFoundError: If skill doesn't exist
SkillExecutionError: If skill execution fails
"""
```
### Tool Methods
#### add_tool()
```python
def add_tool(
self,
tool: Tool
) -> None:
"""
Add a tool to the agent.
Args:
tool: Tool instance to add
Raises:
ValueError: If tool name already exists
"""
```
#### execute_tool()
```python
def execute_tool(
self,
tool_name: str,
parameters: Dict[str, Any]
) -> Dict[str, Any]:
"""
Execute a tool.
Args:
tool_name: Name of the tool to execute
parameters: Parameters for the tool
Returns:
Tool execution result
Raises:
ToolNotFoundError: If tool doesn't exist
ToolExecutionError: If tool execution fails
"""
```
### Properties
#### name
```python
@property
def name(self) -> str:
"""Get the agent name."""
```
#### description
```python
@property
def description(self) -> str:
"""Get the agent description."""
```
#### version
```python
@property
def version(self) -> str:
"""Get the agent version."""
```
#### skills
```python
@property
def skills(self) -> Dict[str, Skill]:
"""Get registered skills."""
```
#### tools
```python
@property
def tools(self) -> Dict[str, Tool]:
"""Get registered tools."""
```
## Configuration Options
### Core Configuration
```yaml
agent:
# Agent identity
name: string # Required: Agent name
description: string # Optional: Agent description
version: string # Optional: Agent version (default: "1.0.0")
# Execution settings
max_iterations: integer # Maximum execution iterations (default: 10)
timeout: integer # Timeout in seconds (default: 300)
temperature: float # LLM temperature (default: 0.7)
# Model configuration
model:
provider: string # LLM provider (openai, anthropic, etc.)
name: string # Model name
api_key: string # API key (can use env vars)
# Resource limits
resources:
memory_limit: string # Memory limit (e.g., "2GB")
cpu_limit: string # CPU limit (e.g., "2")
timeout: integer # Timeout in seconds
```
## Lifecycle Hooks
Agents support the following lifecycle hooks:
### on_initialize()
```python
def on_initialize(self) -> None:
"""Called after agent initialization."""
```
### on_execution_start()
```python
def on_execution_start(self) -> None:
"""Called when execution begins."""
```
### on_execution_end()
```python
def on_execution_end(self, result: Dict[str, Any]) -> None:
"""Called when execution completes."""
```
### on_error()
```python
def on_error(self, error: Exception) -> None:
"""Called when an error occurs."""
```
### on_cleanup()
```python
def on_cleanup(self) -> None:
"""Called during cleanup."""
```
## Error Codes and Exceptions
### Exception Hierarchy
```
AgentError (base exception)
├── SkillExecutionError
├── ToolExecutionError
├── ResourceExhaustedError
├── TimeoutError
├── ValidationError
├── ConfigurationError
└── SkillNotFoundError
```
### AgentError
Base exception for all agent-related errors.
```python
class AgentError(Exception):
"""Base exception for agent errors."""
pass
```
### SkillExecutionError
Raised when skill execution fails.
```python
class SkillExecutionError(AgentError):
"""Raised when skill execution fails."""
def __init__(
self,
skill_name: str,
message: str,
cause: Optional[Exception] = None
):
self.skill_name = skill_name
self.message = message
self.cause = cause
```
### ToolExecutionError
Raised when tool execution fails.
```python
class ToolExecutionError(AgentError):
"""Raised when tool execution fails."""
def __init__(
self,
tool_name: str,
message: str,
cause: Optional[Exception] = None
):
self.tool_name = tool_name
self.message = message
self.cause = cause
```
### ResourceExhaustedError
Raised when resource limits are exceeded.
```python
class ResourceExhaustedError(AgentError):
"""Raised when resource limits are exceeded."""
def __init__(
self,
resource_type: str,
limit: Any,
current: Any
):
self.resource_type = resource_type
self.limit = limit
self.current = current
```
### TimeoutError
Raised when execution timeout is exceeded.
```python
class TimeoutError(AgentError):
"""Raised when execution timeout is exceeded."""
def __init__(
self,
timeout: int,
elapsed: int
):
self.timeout = timeout
self.elapsed = elapsed
```
### ValidationError
Raised when input validation fails.
```python
class ValidationError(AgentError):
"""Raised when input validation fails."""
def __init__(
self,
field: str,
message: str
):
self.field = field
self.message = message
```
## Usage Examples
### Basic Agent Usage
```python
from cleveragents.agents import Agent
# Create agent
agent = Agent(
name="my_agent",
description="My custom agent",
version="1.0.0"
)
# Initialize
agent.initialize({
"max_iterations": 10,
"timeout": 300
})
# Execute
result = agent.execute(
goal="Analyze the provided document",
context={"document": "..."}
)
# Cleanup
agent.cleanup()
```
### With Skills and Tools
```python
# Add skills
agent.add_skill(DocumentAnalysisSkill())
agent.add_skill(SummarizationSkill())
# Add tools
agent.add_tool(FileReaderTool())
agent.add_tool(DatabaseWriterTool())
# Execute skill
result = agent.execute_skill(
"document_analysis",
{"document": content}
)
# Execute tool
result = agent.execute_tool(
"file_reader",
{"file_path": "/path/to/file.txt"}
)
```
## See Also
- [Agent Development Guide](../guides/agent_development_guide.md)
- [Skill API Reference](./skill_api_reference.md)
- [Tool API Reference](./tool_api_reference.md)
+120
View File
@@ -0,0 +1,120 @@
# Provider Configuration Reference
CleverAgents supports multiple AI provider backends through the `ProviderRegistry`.
Each provider is configured via environment variables or the `agents config set` command.
## Supported Providers
| Provider | `ProviderType` | API Key Environment Variable |
|----------|---------------|------------------------------|
| OpenAI | `openai` | `OPENAI_API_KEY` |
| Anthropic | `anthropic` | `ANTHROPIC_API_KEY` |
| Google / Gemini | `google` / `gemini` | `GOOGLE_API_KEY` / `GEMINI_API_KEY` |
| Azure OpenAI | `azure` | `AZURE_OPENAI_API_KEY` |
| **OpenRouter** | **`openrouter`** | **`OPENROUTER_API_KEY`** |
| Groq | `groq` | `GROQ_API_KEY` |
| Together AI | `together` | `TOGETHER_API_KEY` |
| Cohere | `cohere` | `COHERE_API_KEY` |
---
## OpenRouter
[OpenRouter](https://openrouter.ai) provides a unified API gateway to hundreds of
models from different providers (Anthropic, OpenAI, Google, Meta, Mistral, and more).
This makes it ideal for cost optimisation and model diversity without managing
multiple API keys.
### Configuration
Set the following environment variables:
```bash
# Required
export OPENROUTER_API_KEY="sk-or-v1-..."
# Optional: default model (defaults to anthropic/claude-sonnet-4-20250514)
export CLEVERAGENTS_DEFAULT_MODEL="openai/gpt-4o"
# Optional: set OpenRouter as the default provider
export CLEVERAGENTS_DEFAULT_PROVIDER="openrouter"
# Optional: organisation identifier sent in HTTP headers
export OPENROUTER_ORGANIZATION="myapp.example.com"
```
Or use the CLI:
```bash
agents config set provider.openrouter.api_key "sk-or-v1-..."
agents config set provider.openrouter.model "anthropic/claude-3-haiku"
```
### Config Keys
| Config Key | Environment Variable | Description |
|------------|---------------------|-------------|
| `provider.openrouter.api_key` | `OPENROUTER_API_KEY` | OpenRouter API key (required) |
| `provider.openrouter.model` | `CLEVERAGENTS_DEFAULT_MODEL` | Default model slug (optional) |
| `provider.openrouter.organization` | `OPENROUTER_ORGANIZATION` | Organisation name sent in `HTTP-Referer` / `X-Title` headers (optional) |
### Capabilities
| Feature | Supported |
|---------|-----------|
| Streaming | ✅ Yes |
| Tool calls | ✅ Yes |
| Vision / image input | ✅ Yes (model-dependent) |
| JSON mode | ✅ Yes (model-dependent) |
| Max context length | 128 000 tokens (varies by model) |
### Selecting a Model
OpenRouter model slugs follow the format `<provider>/<model-name>`. Examples:
```bash
# Anthropic via OpenRouter
agents config set provider.openrouter.model "anthropic/claude-sonnet-4-20250514"
# OpenAI via OpenRouter
agents config set provider.openrouter.model "openai/gpt-4o"
# Meta Llama via OpenRouter
agents config set provider.openrouter.model "meta-llama/llama-3.1-70b-instruct"
```
Browse the full model catalogue at <https://openrouter.ai/models>.
### Fallback Priority
When no explicit provider is configured, CleverAgents selects the first available
provider in the following fallback order:
1. OpenAI
2. Anthropic
3. Google
4. Azure
5. **OpenRouter** ← position 5
6. Groq
7. Together AI
8. Cohere
### Example Usage
```python
from cleveragents.providers.registry import ProviderRegistry, ProviderType
registry = ProviderRegistry()
# Create a LangChain LLM directly
llm = registry.create_llm(
provider_type=ProviderType.OPENROUTER,
model_id="anthropic/claude-3-haiku",
)
# Or create a full AIProviderInterface adapter
provider = registry.create_ai_provider(
provider_type="openrouter",
model_id="openai/gpt-4o",
)
```
+5494 -18
View File
File diff suppressed because one or more lines are too long
@@ -1,164 +0,0 @@
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
@@ -0,0 +1,87 @@
Feature: OpenRouter provider support in ProviderRegistry
As a developer using CleverAgents
I want ProviderRegistry to handle ProviderType.OPENROUTER in create_llm and _create_provider_llm
So that OpenRouter models are accessible without raising ValueError
# Covers the core acceptance criterion:
# ProviderRegistry.create_llm handles ProviderType.OPENROUTER without raising ValueError
@unit @providers @openrouter @registry
Scenario: create_llm dispatches to OpenRouter via _create_provider_llm
Given I have an openrouter registry with API key "sk-or-test-key"
And the openrouter registry LangChain ChatOpenAI client is stubbed
When I call create_llm on the openrouter registry for provider "openrouter" with model "anthropic/claude-3-haiku"
Then the openrouter registry ChatOpenAI should be called with model "anthropic/claude-3-haiku"
And the openrouter registry ChatOpenAI should use base url "https://openrouter.ai/api/v1"
And the openrouter registry ChatOpenAI should use api key "sk-or-test-key"
@unit @providers @openrouter @registry
Scenario: _create_provider_llm builds ChatOpenAI for OpenRouter with default model
Given I have an openrouter registry with API key "sk-or-default"
And the openrouter registry LangChain ChatOpenAI client is stubbed
When I call _create_provider_llm on the openrouter registry for provider "openrouter" with model None
Then the openrouter registry ChatOpenAI should be called with model "anthropic/claude-sonnet-4-20250514"
And the openrouter registry ChatOpenAI should use base url "https://openrouter.ai/api/v1"
@unit @providers @openrouter @registry
Scenario: _create_provider_llm injects organization headers when configured
Given I have an openrouter registry with API key "sk-or-org" and organization "myapp.example.com"
And the openrouter registry LangChain ChatOpenAI client is stubbed
When I call _create_provider_llm on the openrouter registry for provider "openrouter" with model "openai/gpt-4o"
Then the openrouter registry ChatOpenAI should include header "HTTP-Referer" with value "myapp.example.com"
And the openrouter registry ChatOpenAI should include header "X-Title" with value "myapp.example.com"
@unit @providers @openrouter @registry
Scenario: _create_provider_llm passes custom default_headers through
Given I have an openrouter registry with API key "sk-or-headers"
And the openrouter registry LangChain ChatOpenAI client is stubbed
When I call _create_provider_llm on the openrouter registry for provider "openrouter" with model "openai/gpt-4o" and default_headers "X-Custom=trace-123"
Then the openrouter registry ChatOpenAI should include header "X-Custom" with value "trace-123"
@unit @providers @openrouter @registry
Scenario: create_llm raises ValueError when OpenRouter API key is missing
Given I have an openrouter registry with no API keys
When I try to call create_llm on the openrouter registry for provider "openrouter"
Then an openrouter registry ValueError should be raised
And the openrouter registry error should mention "OPENROUTER_API_KEY"
@unit @providers @openrouter @registry
Scenario: ProviderType.OPENROUTER is in the registry fallback order
Given I have the openrouter ProviderRegistry class
When I check the openrouter FALLBACK_ORDER
Then ProviderType.OPENROUTER should be in the fallback order
@unit @providers @openrouter @registry
Scenario: OpenRouter default model is configured correctly
Given I have the openrouter ProviderRegistry class
When I check the openrouter DEFAULT_MODELS
Then the openrouter default model should be "anthropic/claude-sonnet-4-20250514"
@unit @providers @openrouter @registry
Scenario: OpenRouter capabilities are configured correctly
Given I have the openrouter ProviderRegistry class
When I check the openrouter DEFAULT_CAPABILITIES for OPENROUTER
Then the openrouter provider supports_streaming should be True
And the openrouter provider supports_tool_calls should be True
And the openrouter provider supports_vision should be True
And the openrouter provider max_context_length should be 128000
And the openrouter provider supports_json_mode should be True
@unit @providers @openrouter @registry
Scenario: OpenRouter provider is discovered when API key is set
Given I have an openrouter registry with API key "sk-or-discover"
When I call get_provider_info for OPENROUTER on the openrouter registry
Then the openrouter provider info should be configured
@unit @providers @openrouter @registry
Scenario: OpenRouter provider is not discovered when API key is missing
Given I have an openrouter registry with no API keys
When I call get_provider_info for OPENROUTER on the openrouter registry
Then the openrouter provider info should not be configured
@unit @providers @openrouter @registry
Scenario: create_llm selects OpenRouter as default when it is the only configured provider
Given I have an openrouter registry with API key "sk-or-only"
And the openrouter registry LangChain ChatOpenAI client is stubbed
When I call create_llm on the openrouter registry without specifying a provider
Then the openrouter registry ChatOpenAI should be called with model "anthropic/claude-sonnet-4-20250514"
+4 -4
View File
@@ -72,14 +72,14 @@ Feature: REPL input modes and persona controls
| /persona create ../../etc/cron.d/evil --actor local/mock-default |
Then the REPL mode output should contain "name must not contain path/control separators"
Scenario: Persona export accepts absolute path targets
Scenario: Persona export rejects absolute path targets
Given a temporary REPL config directory
And an absolute persona export path
When I run the REPL with input lines
| line |
| /persona create dev --actor local/mock-default |
| /persona export dev {export_path} |
Then the REPL mode output should contain "Exported persona:"
Then the REPL mode output should contain "Export path must be relative to current working directory"
Scenario: Persona export rejects parent directory escape
Given a temporary REPL config directory
@@ -89,13 +89,13 @@ Feature: REPL input modes and persona controls
| /persona export dev ../outside.yaml |
Then the REPL mode output should contain "Export path must stay within working directory"
Scenario: Persona import accepts absolute path targets
Scenario: Persona import rejects absolute path targets
Given a temporary REPL config directory
And an absolute persona import file path
When I run the REPL with input lines
| line |
| /persona import {absolute_import_path} |
Then the REPL mode output should contain "Imported persona:"
Then the REPL mode output should contain "Import path must be relative to current working directory"
Scenario: Persona binding is independent per REPL session
Given a temporary REPL config directory
@@ -1,641 +0,0 @@
"""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.autonomy_guardrail_service import (
AutonomyGuardrailService,
)
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.autonomy_guardrails import AutonomyGuardrails
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)
def _make_guardrail_service_for_plan(plan_id: str) -> AutonomyGuardrailService:
"""Create an AutonomyGuardrailService with guardrails configured for the plan."""
service = AutonomyGuardrailService()
# Configure guardrails with no step limit or wall clock limit
# so only budget enforcement is tested
guardrails = AutonomyGuardrails(
step_limit=None,
wall_clock_seconds=None,
)
service.configure_guardrails(plan_id, guardrails)
return service
# ---------------------------------------------------------------------------
# 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.
Works for both directly created errors (context.budget_exc) and
errors raised by run_execute (context.budget_raised).
"""
# Try context.budget_exc first (directly created error)
exc = getattr(context, "budget_exc", None)
if exc is None:
# Fall back to context.budget_raised (error raised by run_execute)
exc = getattr(context, "budget_raised", None)
assert exc is not None, "No BudgetExceededError found in context"
assert isinstance(exc, BudgetExceededError), (
f"Expected BudgetExceededError, got {type(exc).__name__}"
)
assert exc.budget_type == expected, (
f"Expected budget_type={expected!r}, got {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}, got {context.budget_exc.used}"
)
@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}, got {context.budget_exc.limit}"
)
@then('the BudgetExceededError message should contain "{text}"')
def step_check_budget_exc_message(context: Context, text: str) -> None:
"""Verify BudgetExceededError message contains text (case-insensitive)."""
msg = str(context.budget_exc).lower()
assert text.lower() in msg, (
f"Expected '{text}' (case-insensitive) 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 (case-insensitive)."""
msg = str(context.plan_budget_exc).lower()
assert text.lower() in msg, (
f"Expected '{text}' (case-insensitive) 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()
# Create guardrail service so _enforce_guardrails_per_step calls _check_budget
guardrail_service = _make_guardrail_service_for_plan(_BUDGET_PLAN_ID)
context.budget_executor = PlanExecutor(
lifecycle_service=context.budget_lifecycle,
cost_tracker=context.budget_cost_tracker,
cost_metadata=context.budget_cost_metadata,
guardrail_service=guardrail_service,
)
@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()
# Create guardrail service so _enforce_guardrails_per_step calls _check_budget
guardrail_service = _make_guardrail_service_for_plan(_BUDGET_PLAN_ID)
context.budget_executor = PlanExecutor(
lifecycle_service=context.budget_lifecycle,
cost_tracker=context.budget_cost_tracker,
cost_metadata=context.budget_cost_metadata,
guardrail_service=guardrail_service,
)
@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 (budget halt saves plan state before re-raising).
The _save_plan_state_on_budget_halt method calls _commit_plan before raising
the budget exception. The outer execute handler may also call _commit_plan
with different error_details. We verify _commit_plan was called at least once.
"""
assert context.budget_lifecycle._commit_plan.called, (
"Expected _commit_plan to be called by _save_plan_state_on_budget_halt"
)
# ---------------------------------------------------------------------------
# _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}"
)
+5 -11
View File
@@ -755,6 +755,11 @@ def step_try_create_plan_empty_desc(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 create an edge case plan with invalid phase value")
def step_try_create_plan_invalid_phase(context: Context) -> None:
"""Attempt to create a plan with a non-existent phase value."""
@@ -903,14 +908,3 @@ def step_check_post_fail_complete_error(context: Context) -> None:
"Expected PlanError when completing after failure"
)
assert isinstance(context.post_fail_complete_error, PlanError)
@then("a Pydantic validation error should be raised")
def step_check_pydantic_validation_error(context: Context) -> None:
"""Assert that a Pydantic validation error was raised."""
assert context.pydantic_error is not None, (
"Expected a Pydantic validation error to be raised"
)
assert isinstance(context.pydantic_error, (PydanticValidationError, ValueError)), (
f"Expected PydanticValidationError or ValueError, got {type(context.pydantic_error).__name__}"
)
@@ -0,0 +1,374 @@
"""Step definitions for openrouter_provider_registry.feature.
Tests that ProviderRegistry.create_llm and _create_provider_llm correctly
dispatch to the OpenRouter ChatOpenAI backend without raising ValueError.
"""
from __future__ import annotations
import sys
import types
from typing import Any
from unittest.mock import MagicMock
from behave import given, then, use_step_matcher, when # type: ignore[import-untyped]
from cleveragents.providers.registry import (
ProviderRegistry,
ProviderType,
)
_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_or_settings(
openrouter: str | None = None,
organization: str | None = None,
) -> MagicMock:
"""Return a minimal fake Settings object for OpenRouter tests."""
settings = MagicMock()
settings.openai_api_key = None
settings.anthropic_api_key = None
settings.google_api_key = None
settings.gemini_api_key = None
settings.azure_api_key = None
settings.openrouter_api_key = openrouter
settings.cohere_api_key = None
settings.groq_api_key = None
settings.together_api_key = None
settings.default_provider = None
settings.default_model = None
settings.azure_openai_endpoint = None
settings.azure_openai_api_version = None
settings.azure_openai_deployment = None
settings.openrouter_organization = organization
return settings
def _stub_chat_openai(context: Any) -> None:
"""Replace langchain_openai.ChatOpenAI with a recording stub."""
original = sys.modules.get("langchain_openai")
calls: list[dict[str, Any]] = []
class FakeChatOpenAI:
def __init__(self, **kwargs: Any) -> None:
calls.append(kwargs)
class FakeAzureChatOpenAI:
def __init__(self, **kwargs: Any) -> None:
pass
stub = types.ModuleType("langchain_openai")
stub.ChatOpenAI = FakeChatOpenAI # type: ignore[attr-defined]
stub.AzureChatOpenAI = FakeAzureChatOpenAI # type: ignore[attr-defined]
sys.modules["langchain_openai"] = stub
context._or_chat_openai_calls = calls
context._or_chat_openai_cls = FakeChatOpenAI
if not hasattr(context, "_or_cleanup"):
context._or_cleanup = []
def _restore() -> None:
if original is None:
sys.modules.pop("langchain_openai", None)
else:
sys.modules["langchain_openai"] = original
context._or_cleanup.append(_restore)
def _parse_headers_string(headers_string: str) -> dict[str, str]:
"""Parse 'Key=Value,Key2=Value2' into a dict."""
result: dict[str, str] = {}
for entry in headers_string.split(","):
entry = entry.strip()
if "=" in entry:
key, value = entry.split("=", 1)
result[key.strip()] = value.strip()
return result
# ---------------------------------------------------------------------------
# Given
# ---------------------------------------------------------------------------
use_step_matcher("re")
@given(
r'I have an openrouter registry with API key "(?P<or_api_key>[^"]+)" and organization "(?P<or_org>[^"]+)"'
)
def step_or_registry_with_key_and_org(
context: Any, or_api_key: str, or_org: str
) -> None:
context.or_registry = ProviderRegistry(
settings=_make_or_settings(openrouter=or_api_key, organization=or_org)
)
@given(r'I have an openrouter registry with API key "(?P<or_api_key>[^"]+)"')
def step_or_registry_with_key(context: Any, or_api_key: str) -> None:
context.or_registry = ProviderRegistry(
settings=_make_or_settings(openrouter=or_api_key)
)
@given(r"I have an openrouter registry with no API keys")
def step_or_registry_no_keys(context: Any) -> None:
context.or_registry = ProviderRegistry(settings=_make_or_settings())
@given(r"I have the openrouter ProviderRegistry class")
def step_or_registry_class(context: Any) -> None:
context.or_registry_class = ProviderRegistry
@given(r"the openrouter registry LangChain ChatOpenAI client is stubbed")
def step_or_stub_chat_openai(context: Any) -> None:
_stub_chat_openai(context)
# ---------------------------------------------------------------------------
# When
# ---------------------------------------------------------------------------
@when(
r'I call create_llm on the openrouter registry for provider "openrouter" with model "(?P<or_model>[^"]+)"'
)
def step_or_create_llm_with_model(context: Any, or_model: str) -> None:
context.or_error = None
try:
context.or_result = context.or_registry.create_llm(
provider_type="openrouter", model_id=or_model
)
except ValueError as exc:
context.or_error = exc
@when(r"I call create_llm on the openrouter registry without specifying a provider")
def step_or_create_llm_default(context: Any) -> None:
context.or_error = None
try:
context.or_result = context.or_registry.create_llm()
except ValueError as exc:
context.or_error = exc
@when(
r'I call _create_provider_llm on the openrouter registry for provider "openrouter" with model None'
)
def step_or_private_llm_none_model(context: Any) -> None:
context.or_error = None
try:
context.or_result = context.or_registry._create_provider_llm(
ProviderType.OPENROUTER, None
)
except ValueError as exc:
context.or_error = exc
@when(
r'I call _create_provider_llm on the openrouter registry for provider "openrouter" with model "(?P<or_model>[^"]+)" and default_headers "(?P<or_headers_string>[^"]+)"'
)
def step_or_private_llm_with_headers(
context: Any, or_model: str, or_headers_string: str
) -> None:
headers = _parse_headers_string(or_headers_string)
context.or_error = None
try:
context.or_result = context.or_registry._create_provider_llm(
ProviderType.OPENROUTER, or_model, default_headers=headers
)
except ValueError as exc:
context.or_error = exc
@when(
r'I call _create_provider_llm on the openrouter registry for provider "openrouter" with model "(?P<or_model>[^"]+)"'
)
def step_or_private_llm_with_model(context: Any, or_model: str) -> None:
context.or_error = None
try:
context.or_result = context.or_registry._create_provider_llm(
ProviderType.OPENROUTER, or_model
)
except ValueError as exc:
context.or_error = exc
@when(r'I try to call create_llm on the openrouter registry for provider "openrouter"')
def step_or_try_create_llm_no_key(context: Any) -> None:
context.or_error = None
try:
context.or_registry.create_llm(provider_type="openrouter")
except ValueError as exc:
context.or_error = exc
@when(r"I check the openrouter FALLBACK_ORDER")
def step_or_check_fallback_order(context: Any) -> None:
context.or_fallback_order = context.or_registry_class.FALLBACK_ORDER
@when(r"I check the openrouter DEFAULT_MODELS")
def step_or_check_default_models(context: Any) -> None:
context.or_default_models = context.or_registry_class.DEFAULT_MODELS
@when(r"I check the openrouter DEFAULT_CAPABILITIES for OPENROUTER")
def step_or_check_capabilities(context: Any) -> None:
context.or_capabilities = context.or_registry_class.DEFAULT_CAPABILITIES.get(
ProviderType.OPENROUTER
)
@when(r"I call get_provider_info for OPENROUTER on the openrouter registry")
def step_or_get_provider_info(context: Any) -> None:
context.or_provider_info = context.or_registry.get_provider_info(
ProviderType.OPENROUTER
)
# ---------------------------------------------------------------------------
# Then
# ---------------------------------------------------------------------------
@then(
r'the openrouter registry ChatOpenAI should be called with model "(?P<or_expected_model>[^"]+)"'
)
def step_or_assert_model(context: Any, or_expected_model: str) -> None:
calls = getattr(context, "_or_chat_openai_calls", [])
assert calls, "Expected ChatOpenAI to be instantiated"
actual_model = calls[-1].get("model")
assert actual_model == or_expected_model, (
f"Expected model={or_expected_model!r}, got {actual_model!r}"
)
@then(
r'the openrouter registry ChatOpenAI should use base url "(?P<or_expected_url>[^"]+)"'
)
def step_or_assert_base_url(context: Any, or_expected_url: str) -> None:
calls = getattr(context, "_or_chat_openai_calls", [])
assert calls, "Expected ChatOpenAI to be instantiated"
actual_url = calls[-1].get("openai_api_base")
assert actual_url == or_expected_url, (
f"Expected openai_api_base={or_expected_url!r}, got {actual_url!r}"
)
@then(
r'the openrouter registry ChatOpenAI should use api key "(?P<or_expected_key>[^"]+)"'
)
def step_or_assert_api_key(context: Any, or_expected_key: str) -> None:
calls = getattr(context, "_or_chat_openai_calls", [])
assert calls, "Expected ChatOpenAI to be instantiated"
actual_key = calls[-1].get("openai_api_key")
assert actual_key == or_expected_key, (
f"Expected openai_api_key={or_expected_key!r}, got {actual_key!r}"
)
@then(
r'the openrouter registry ChatOpenAI should include header "(?P<or_header_name>[^"]+)" with value "(?P<or_expected_value>[^"]+)"'
)
def step_or_assert_header(
context: Any, or_header_name: str, or_expected_value: str
) -> None:
calls = getattr(context, "_or_chat_openai_calls", [])
assert calls, "Expected ChatOpenAI to be instantiated"
headers = calls[-1].get("default_headers")
assert isinstance(headers, dict), (
f"Expected default_headers to be a dict, got {type(headers)}"
)
actual_value = headers.get(or_header_name)
assert actual_value == or_expected_value, (
f"Expected header {or_header_name}={or_expected_value!r}, got {actual_value!r}"
)
@then(r"an openrouter registry ValueError should be raised")
def step_or_assert_value_error(context: Any) -> None:
assert context.or_error is not None, "Expected a ValueError to be raised"
assert isinstance(context.or_error, ValueError), (
f"Expected ValueError, got {type(context.or_error).__name__}"
)
@then(r'the openrouter registry error should mention "(?P<or_fragment>[^"]+)"')
def step_or_assert_error_contains(context: Any, or_fragment: str) -> None:
assert context.or_error is not None, "Expected an error to be set"
message = str(context.or_error)
assert or_fragment in message, (
f"Expected error to contain {or_fragment!r}, got: {message!r}"
)
@then(r"ProviderType\.OPENROUTER should be in the fallback order")
def step_or_assert_in_fallback(context: Any) -> None:
assert ProviderType.OPENROUTER in context.or_fallback_order, (
"Expected ProviderType.OPENROUTER to be in FALLBACK_ORDER"
)
@then(r'the openrouter default model should be "(?P<or_expected_model>[^"]+)"')
def step_or_assert_default_model(context: Any, or_expected_model: str) -> None:
actual = context.or_default_models.get(ProviderType.OPENROUTER)
assert actual == or_expected_model, (
f"Expected default model={or_expected_model!r}, got {actual!r}"
)
@then(r"the openrouter provider supports_streaming should be (?P<or_expected>\w+)")
def step_or_assert_streaming(context: Any, or_expected: str) -> None:
assert context.or_capabilities is not None
assert context.or_capabilities.supports_streaming is (or_expected == "True")
@then(r"the openrouter provider supports_tool_calls should be (?P<or_expected>\w+)")
def step_or_assert_tool_calls(context: Any, or_expected: str) -> None:
assert context.or_capabilities is not None
assert context.or_capabilities.supports_tool_calls is (or_expected == "True")
@then(r"the openrouter provider supports_vision should be (?P<or_expected>\w+)")
def step_or_assert_vision(context: Any, or_expected: str) -> None:
assert context.or_capabilities is not None
assert context.or_capabilities.supports_vision is (or_expected == "True")
@then(r"the openrouter provider max_context_length should be (?P<or_expected>\d+)")
def step_or_assert_context_length(context: Any, or_expected: str) -> None:
assert context.or_capabilities is not None
expected_int = int(or_expected)
assert context.or_capabilities.max_context_length == expected_int, (
f"Expected max_context_length={expected_int}, got {context.or_capabilities.max_context_length}"
)
@then(r"the openrouter provider supports_json_mode should be (?P<or_expected>\w+)")
def step_or_assert_json_mode(context: Any, or_expected: str) -> None:
assert context.or_capabilities is not None
assert context.or_capabilities.supports_json_mode is (or_expected == "True")
@then(r"the openrouter provider info should be configured")
def step_or_assert_provider_configured(context: Any) -> None:
info = context.or_provider_info
assert info is not None, "Expected provider info to exist"
assert info.is_configured is True, "Expected provider to be configured"
@then(r"the openrouter provider info should not be configured")
def step_or_assert_provider_not_configured(context: Any) -> None:
info = context.or_provider_info
assert info is not None, "Expected provider info to exist"
assert info.is_configured is False, "Expected provider to not be configured"
+3 -9
View File
@@ -273,15 +273,9 @@ def step_instantiate_app(context):
)
@then('the app should have a default session with session_id "{sid}"')
def step_app_default_session_id(context, sid):
"""Check the default session in the multi-session app."""
# The app uses _sessions (list) instead of _session (single)
assert hasattr(context._tui_app, "_sessions"), (
"App should have _sessions attribute"
)
assert len(context._tui_app._sessions) > 0, "App should have at least one session"
assert context._tui_app._sessions[0].session_id == sid
@then('the app should have a _session with session_id "{sid}"')
def step_app_session_id(context, sid):
assert context._tui_app._session.session_id == sid
@then("the app should store the command router")
@@ -1,332 +0,0 @@
"""Step definitions for tui/block_cursor_navigation.feature.
These steps target TDD Issue #10491: TUI BINDINGS missing alt+up and alt+down
block cursor navigation keys.
Tests verify:
- ``alt+up`` is present in ``BINDINGS`` and maps to ``cursor_up`` action
- ``alt+down`` is present in ``BINDINGS`` and maps to ``cursor_down`` action
- ``action_cursor_up()`` method exists and moves block cursor up
- ``action_cursor_down()`` method exists and moves block cursor down
- Edge cases: cursor at top (alt+up does nothing), cursor at bottom (alt+down does nothing)
All scenarios are tagged @tdd_expected_fail because the implementation does not
yet exist. When bug #10491 is fixed, the @tdd_expected_fail tag must be removed.
"""
from __future__ import annotations
import importlib
import shutil
import sys
import tempfile
from pathlib import Path
from types import ModuleType
from unittest.mock import MagicMock
from behave import given, then, when
# ---------------------------------------------------------------------------
# Mock Textual infrastructure (mirrors tui_app_coverage_steps.py)
# ---------------------------------------------------------------------------
_MOCK_TEXTUAL_KEYS = [
"textual",
"textual.app",
"textual.containers",
"textual.widgets",
]
def _build_mock_textual_for_cursor() -> dict[str, ModuleType]:
"""Build mock textual modules that satisfy the app import gate."""
mock_textual = ModuleType("textual")
mock_textual_app = ModuleType("textual.app")
mock_textual_containers = ModuleType("textual.containers")
mock_textual_widgets = ModuleType("textual.widgets")
class MockApp:
"""Minimal App stand-in for the Textual base class."""
def __init__(self, *args: object, **kwargs: object) -> None:
self._widgets: dict[str, object] = {}
def query_one(self, selector: str, widget_type: type | None = None) -> object:
if selector in self._widgets:
return self._widgets[selector]
if widget_type is not None:
widget = widget_type(id=selector.lstrip("#"))
self._widgets[selector] = widget
return widget
return MagicMock()
class MockVertical:
def __init__(self, *args: object, **kwargs: object) -> None:
pass
def __enter__(self) -> MockVertical:
return self
def __exit__(self, *args: object) -> None:
pass
class MockHeader:
def __init__(self, *args: object, **kwargs: object) -> None:
pass
class MockFooter:
def __init__(self, *args: object, **kwargs: object) -> None:
pass
class MockStatic:
def __init__(self, *args: object, **kwargs: object) -> None:
self._text = ""
def update(self, text: str) -> None:
self._text = text
class MockInput:
"""Minimal Input stand-in for the Textual base class."""
value: str = ""
def __init__(self, *args: object, **kwargs: object) -> None:
self.value = ""
mock_textual_app.App = MockApp # type: ignore[attr-defined]
mock_textual_containers.Vertical = MockVertical # type: ignore[attr-defined]
mock_textual_widgets.Header = MockHeader # type: ignore[attr-defined]
mock_textual_widgets.Footer = MockFooter # type: ignore[attr-defined]
mock_textual_widgets.Static = MockStatic # type: ignore[attr-defined]
mock_textual_widgets.Input = MockInput # type: ignore[attr-defined]
return {
"textual": mock_textual,
"textual.app": mock_textual_app,
"textual.containers": mock_textual_containers,
"textual.widgets": mock_textual_widgets,
}
def _install_mock_textual_for_cursor(context: object) -> None:
"""Inject mock textual into sys.modules and reload the app module."""
mocks = _build_mock_textual_for_cursor()
context._cursor_saved_modules = {} # type: ignore[attr-defined]
for key in _MOCK_TEXTUAL_KEYS:
context._cursor_saved_modules[key] = sys.modules.pop(key, None) # type: ignore[attr-defined]
for key, mod in mocks.items():
sys.modules[key] = mod
# Reload widget modules so they pick up the mock Static/Input base class
import cleveragents.tui.widgets.help_panel_overlay as hp_mod
import cleveragents.tui.widgets.persona_bar as pb_mod
import cleveragents.tui.widgets.prompt as prompt_mod
import cleveragents.tui.widgets.reference_picker as rp_mod
import cleveragents.tui.widgets.slash_command_overlay as sco_mod
importlib.reload(hp_mod)
importlib.reload(pb_mod)
importlib.reload(prompt_mod)
importlib.reload(rp_mod)
importlib.reload(sco_mod)
import cleveragents.tui.app as app_mod
importlib.reload(app_mod)
context._cursor_app_mod = app_mod # type: ignore[attr-defined]
def _restore_modules_for_cursor(context: object) -> None:
"""Restore original sys.modules and reload the app module."""
for key, val in getattr(context, "_cursor_saved_modules", {}).items():
if val is None:
sys.modules.pop(key, None)
else:
sys.modules[key] = val
import cleveragents.tui.widgets.help_panel_overlay as hp_mod
import cleveragents.tui.widgets.persona_bar as pb_mod
import cleveragents.tui.widgets.prompt as prompt_mod
import cleveragents.tui.widgets.reference_picker as rp_mod
import cleveragents.tui.widgets.slash_command_overlay as sco_mod
importlib.reload(hp_mod)
importlib.reload(pb_mod)
importlib.reload(prompt_mod)
importlib.reload(rp_mod)
importlib.reload(sco_mod)
import cleveragents.tui.app as app_mod
importlib.reload(app_mod)
def _make_persona_state_for_cursor(context: object) -> object:
"""Create a real PersonaState backed by a temp directory."""
from cleveragents.tui.persona.registry import PersonaRegistry
from cleveragents.tui.persona.state import PersonaState
tmp = tempfile.mkdtemp()
context._cursor_tmpdir = tmp # type: ignore[attr-defined]
registry = PersonaRegistry(config_dir=Path(tmp))
registry.ensure_default()
return PersonaState(registry=registry)
def _cleanup_cursor_tmpdir(context: object) -> None:
tmp = getattr(context, "_cursor_tmpdir", None)
if tmp:
shutil.rmtree(tmp, ignore_errors=True)
# ---------------------------------------------------------------------------
# Background steps
# ---------------------------------------------------------------------------
@given("the TUI app module is imported with mocked Textual for cursor tests")
def step_import_with_mock_textual_cursor(context: object) -> None:
"""Install mock Textual, reload app module, register cleanup."""
_install_mock_textual_for_cursor(context)
context.add_cleanup(lambda: _restore_modules_for_cursor(context)) # type: ignore[attr-defined]
context.add_cleanup(lambda: _cleanup_cursor_tmpdir(context)) # type: ignore[attr-defined]
@given("a mock command router and persona state for cursor tests")
def step_create_mock_deps_cursor(context: object) -> None:
class _FakeCmdRouter:
def handle(self, raw: str, *, session_id: str) -> str:
return f"handled:{raw}"
context._cursor_cmd_router = _FakeCmdRouter() # type: ignore[attr-defined]
context._cursor_persona_state = _make_persona_state_for_cursor(context) # type: ignore[attr-defined]
@given("the Textual TUI app is instantiated for cursor tests")
def step_instantiate_app_cursor(context: object) -> None:
AppClass = context._cursor_app_mod._ResolvedTuiApp # type: ignore[attr-defined]
context._cursor_app = AppClass( # type: ignore[attr-defined]
command_router=context._cursor_cmd_router, # type: ignore[attr-defined]
persona_state=context._cursor_persona_state, # type: ignore[attr-defined]
)
# ---------------------------------------------------------------------------
# BINDINGS assertions
# ---------------------------------------------------------------------------
@then('the BINDINGS list should contain an entry for "{key}"')
def step_bindings_contains_key(context: object, key: str) -> None:
"""Assert that the given key is present in the BINDINGS list."""
app = context._cursor_app # type: ignore[attr-defined]
binding_keys = [b[0] for b in app.BINDINGS]
assert key in binding_keys, (
f"Expected '{key}' in BINDINGS but found: {binding_keys}"
)
@then('the BINDINGS entry for "{key}" should map to action "{action}"')
def step_bindings_key_maps_to_action(context: object, key: str, action: str) -> None:
"""Assert that the given key maps to the expected action in BINDINGS."""
app = context._cursor_app # type: ignore[attr-defined]
matching = [b for b in app.BINDINGS if b[0] == key]
assert matching, f"No BINDINGS entry found for key '{key}'"
binding_action = matching[0][1]
assert binding_action == action, (
f"Expected BINDINGS['{key}'] action to be '{action}' but got '{binding_action}'"
)
# ---------------------------------------------------------------------------
# Method existence assertions
# ---------------------------------------------------------------------------
@then("the TUI app should have an action_cursor_up method")
def step_app_has_cursor_up(context: object) -> None:
"""Assert that action_cursor_up() method exists on the TUI app."""
app = context._cursor_app # type: ignore[attr-defined]
assert hasattr(app, "action_cursor_up") and callable(
app.action_cursor_up # type: ignore[attr-defined]
), "TUI app is missing action_cursor_up() method"
@then("the TUI app should have an action_cursor_down method")
def step_app_has_cursor_down(context: object) -> None:
"""Assert that action_cursor_down() method exists on the TUI app."""
app = context._cursor_app # type: ignore[attr-defined]
assert hasattr(app, "action_cursor_down") and callable(
app.action_cursor_down # type: ignore[attr-defined]
), "TUI app is missing action_cursor_down() method"
# ---------------------------------------------------------------------------
# Block cursor navigation steps
# ---------------------------------------------------------------------------
_SAMPLE_BLOCKS = [
"UserInput: Hello",
"ActorResponse: Hi there",
"ToolCall: search(query='test')",
"PlanProgress: Step 1 complete",
"ActorResponse: Done",
]
@given("the TUI app has conversation blocks loaded")
def step_load_conversation_blocks(context: object) -> None:
"""Load sample conversation blocks into the TUI app."""
app = context._cursor_app # type: ignore[attr-defined]
# The app should expose _conversation_blocks for block cursor navigation
app._conversation_blocks = list(_SAMPLE_BLOCKS) # type: ignore[attr-defined]
app._block_cursor_index = 0 # type: ignore[attr-defined]
@given("the block cursor is positioned at index {index:d}")
def step_set_cursor_index(context: object, index: int) -> None:
"""Set the block cursor to a specific index."""
app = context._cursor_app # type: ignore[attr-defined]
app._block_cursor_index = index # type: ignore[attr-defined]
@given("the block cursor is positioned at the last block")
def step_set_cursor_at_last(context: object) -> None:
"""Set the block cursor to the last block."""
app = context._cursor_app # type: ignore[attr-defined]
app._block_cursor_index = len(app._conversation_blocks) - 1 # type: ignore[attr-defined]
@when("action_cursor_up is called on the TUI app")
def step_call_cursor_up(context: object) -> None:
"""Call action_cursor_up() on the TUI app."""
app = context._cursor_app # type: ignore[attr-defined]
app.action_cursor_up() # type: ignore[attr-defined]
@when("action_cursor_down is called on the TUI app")
def step_call_cursor_down(context: object) -> None:
"""Call action_cursor_down() on the TUI app."""
app = context._cursor_app # type: ignore[attr-defined]
app.action_cursor_down() # type: ignore[attr-defined]
@then("the block cursor index should be {expected:d}")
def step_assert_cursor_index(context: object, expected: int) -> None:
"""Assert the block cursor is at the expected index."""
app = context._cursor_app # type: ignore[attr-defined]
actual = app._block_cursor_index # type: ignore[attr-defined]
assert actual == expected, (
f"Expected block cursor index {expected} but got {actual}"
)
@then("the block cursor index should be at the last block")
def step_assert_cursor_at_last(context: object) -> None:
"""Assert the block cursor is at the last block."""
app = context._cursor_app # type: ignore[attr-defined]
last_index = len(app._conversation_blocks) - 1 # type: ignore[attr-defined]
actual = app._block_cursor_index # type: ignore[attr-defined]
assert actual == last_index, (
f"Expected block cursor at last index {last_index} but got {actual}"
)
@@ -1,372 +0,0 @@
"""Step definitions for TUI multi-session tabs feature."""
from __future__ import annotations
from datetime import datetime
from behave import given, then, when
from cleveragents.tui.app import SessionView
from cleveragents.tui.persona.registry import PersonaRegistry
from cleveragents.tui.persona.state import PersonaState
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}"
def _setup_mock_app(context: object) -> None:
"""Set up a mock app with session management if not already set up."""
if not hasattr(context, "app") or context.app is None: # type: ignore
context.app = type("MockApp", (), {})() # type: ignore
context.app._sessions = [ # type: ignore
SessionView(
session_id="default",
transcript=[],
name="Default",
created_at=datetime.utcnow().isoformat(),
)
]
context.app._active_session_index = 0 # type: ignore
def _create_sessions(context: object, count: int) -> None:
"""Create a mock app with the specified number of sessions."""
context.app = type("MockApp", (), {})() # type: ignore
context.app._sessions = [] # type: ignore
# Use predictable session IDs for testing
session_ids = ["default", "sess-2", "sess-3", "sess-4", "sess-5"]
session_names = ["Default", "Session 2", "Session 3", "Session 4", "Session 5"]
for i in range(count):
session_id = session_ids[i] if i < len(session_ids) else f"sess-{i + 1}"
name = session_names[i] if i < len(session_names) else f"Session {i + 1}"
session = SessionView(
session_id=session_id,
transcript=[],
name=name,
created_at=datetime.utcnow().isoformat(),
)
context.app._sessions.append(session) # type: ignore
context.app._active_session_index = 0 # type: ignore
@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.registry = PersonaRegistry() # type: ignore
context.persona_state = PersonaState(registry=context.registry) # type: ignore
context.router = MockCommandRouter() # type: ignore
context.app = None # type: ignore
context.close_failed = False # type: ignore
context.new_session = None # type: ignore
@when("the TUI app is created")
def step_create_tui_app(context: object) -> None:
"""Create a TUI app instance."""
context.app = type("MockApp", (), {})() # type: ignore
context.app._sessions = [ # type: ignore
SessionView(
session_id="default",
transcript=[],
name="Default",
created_at=datetime.utcnow().isoformat(),
)
]
context.app._active_session_index = 0 # type: ignore
@then("the app should have exactly {count:d} session")
def step_check_session_count_singular(context: object, count: int) -> None:
"""Check the number of sessions (singular form)."""
_setup_mock_app(context)
assert len(context.app._sessions) == count # type: ignore
@then("the app should have exactly {count:d} sessions")
def step_check_session_count_plural(context: object, count: int) -> None:
"""Check the number of sessions (plural form)."""
_setup_mock_app(context)
assert len(context.app._sessions) == count # type: ignore
@then('the active session should have session_id "{session_id}"')
def step_check_active_session_id(context: object, session_id: str) -> None:
"""Check the active session ID."""
_setup_mock_app(context)
active = context.app._sessions[context.app._active_session_index] # type: ignore
assert active.session_id == session_id, (
f"Expected session_id '{session_id}' but got '{active.session_id}'"
)
@then('the active session should have name "{name}"')
def step_check_active_session_name(context: object, name: str) -> None:
"""Check the active session name."""
_setup_mock_app(context)
active = context.app._sessions[context.app._active_session_index] # type: ignore
assert active.name == name, (
f"Expected name '{name}' but got '{active.name}'"
)
@when('I create a new session with name "{name}"')
def step_create_session(context: object, name: str) -> None:
"""Create a new session."""
import uuid
_setup_mock_app(context)
session_id = str(uuid.uuid4())[:8]
new_session = SessionView(
session_id=session_id,
transcript=[],
name=name,
created_at=datetime.utcnow().isoformat(),
)
context.app._sessions.append(new_session) # type: ignore
context.app._active_session_index = len(context.app._sessions) - 1 # type: ignore
@then("the new session should have an independent session_id")
def step_check_new_session_id(context: object) -> None:
"""Check that the new session has a unique ID."""
_setup_mock_app(context)
sessions = context.app._sessions # type: ignore
session_ids = [s.session_id for s in sessions]
assert len(session_ids) == len(set(session_ids)) # All unique
@given("the TUI app has {count:d} session")
def step_setup_sessions_singular(context: object, count: int) -> None:
"""Set up the TUI app with a specific number of sessions (singular)."""
_create_sessions(context, count)
@given("the TUI app has {count:d} sessions")
def step_setup_sessions_plural(context: object, count: int) -> None:
"""Set up the TUI app with a specific number of sessions (plural)."""
_create_sessions(context, count)
@given('the first session has session_id "{session_id}"')
def step_check_first_session_id(context: object, session_id: str) -> None:
"""Verify the first session has the expected ID."""
_setup_mock_app(context)
assert context.app._sessions[0].session_id == session_id # type: ignore
@given('the second session has session_id "{session_id}"')
def step_check_second_session_id(context: object, session_id: str) -> None:
"""Verify the second session has the expected ID."""
_setup_mock_app(context)
# If the second session doesn't have the expected ID, update it
if len(context.app._sessions) > 1: # type: ignore
context.app._sessions[1].session_id = session_id # type: ignore
@when('I switch to session "{session_id}"')
def step_switch_session(context: object, session_id: str) -> None:
"""Switch to a specific session."""
_setup_mock_app(context)
for idx, session in enumerate(context.app._sessions): # type: ignore
if session.session_id == session_id:
context.app._active_session_index = idx # type: ignore
return
raise ValueError(f"Session {session_id} not found")
@when("I switch to the second session")
def step_switch_to_second_session(context: object) -> None:
"""Switch to the second session."""
_setup_mock_app(context)
assert len(context.app._sessions) >= 2, "Need at least 2 sessions" # type: ignore
context.app._active_session_index = 1 # type: ignore
@when('I close the session with session_id "{session_id}"')
def step_close_session(context: object, session_id: str) -> None:
"""Close a session."""
_setup_mock_app(context)
if len(context.app._sessions) <= 1: # type: ignore
context.close_failed = True # type: ignore
return
for idx, session in enumerate(context.app._sessions): # type: ignore
if session.session_id == session_id:
context.app._sessions.pop(idx) # type: ignore
if context.app._active_session_index >= len(context.app._sessions): # type: ignore
context.app._active_session_index = len(context.app._sessions) - 1 # type: ignore
context.close_failed = False # type: ignore
return
raise ValueError(f"Session {session_id} not found")
@when('I try to close the session with session_id "{session_id}"')
def step_try_close_session(context: object, session_id: str) -> None:
"""Try to close a session (may fail)."""
_setup_mock_app(context)
context.close_failed = False # type: ignore
if len(context.app._sessions) <= 1: # type: ignore
context.close_failed = True # type: ignore
return
for idx, session in enumerate(context.app._sessions): # type: ignore
if session.session_id == session_id:
context.app._sessions.pop(idx) # type: ignore
if context.app._active_session_index >= len(context.app._sessions): # type: ignore
context.app._active_session_index = len(context.app._sessions) - 1 # type: ignore
return
@then("the close operation should fail")
def step_check_close_failed(context: object) -> None:
"""Check that the close operation failed."""
assert context.close_failed # type: ignore
@then("the app should still have exactly {count:d} session")
def step_check_session_count_still_singular(context: object, count: int) -> None:
"""Check the number of sessions (after failed close, singular)."""
_setup_mock_app(context)
assert len(context.app._sessions) == count # type: ignore
@then("the app should still have exactly {count:d} sessions")
def step_check_session_count_still_plural(context: object, count: int) -> None:
"""Check the number of sessions (after failed close, plural)."""
_setup_mock_app(context)
assert len(context.app._sessions) == count # type: ignore
@when('I rename the session to "{new_name}"')
def step_rename_session(context: object, new_name: str) -> None:
"""Rename the active session."""
_setup_mock_app(context)
active = context.app._sessions[context.app._active_session_index] # type: ignore
active.name = new_name
@given('the active session has name "{name}"')
def step_check_active_session_has_name(context: object, name: str) -> None:
"""Verify the active session has a specific name."""
_setup_mock_app(context)
active = context.app._sessions[context.app._active_session_index] # type: ignore
assert active.name == name, (
f"Expected name '{name}' but got '{active.name}'"
)
@given("the first session is active")
def step_first_session_active(context: object) -> None:
"""Make the first session active."""
_setup_mock_app(context)
context.app._active_session_index = 0 # type: ignore
@when('I set persona "{persona_name}" for the first session')
def step_set_persona_first(context: object, persona_name: str) -> None:
"""Set persona for the first session."""
_setup_mock_app(context)
session_id = context.app._sessions[0].session_id # type: ignore
context.persona_state.active_by_session[session_id] = persona_name # type: ignore
@when('I set persona "{persona_name}" for the second session')
def step_set_persona_second(context: object, persona_name: str) -> None:
"""Set persona for the second session."""
_setup_mock_app(context)
session_id = context.app._sessions[1].session_id # type: ignore
context.persona_state.active_by_session[session_id] = persona_name # type: ignore
@when("I switch back to the first session")
def step_switch_back_to_first(context: object) -> None:
"""Switch back to the first session."""
_setup_mock_app(context)
context.app._active_session_index = 0 # type: ignore
@then('the first session should have active persona "{persona_name}"')
def step_check_first_session_persona(context: object, persona_name: str) -> None:
"""Check the first session's active persona."""
_setup_mock_app(context)
session_id = context.app._sessions[0].session_id # type: ignore
assert context.persona_state.active_by_session.get(session_id) == persona_name # type: ignore
@then('the second session should have active persona "{persona_name}"')
def step_check_second_session_persona(context: object, persona_name: str) -> None:
"""Check the second session's active persona."""
_setup_mock_app(context)
session_id = context.app._sessions[1].session_id # type: ignore
assert context.persona_state.active_by_session.get(session_id) == persona_name # type: ignore
@when('I add message "{message}" to the first session')
def step_add_message_first(context: object, message: str) -> None:
"""Add a message to the first session."""
_setup_mock_app(context)
context.app._sessions[0].transcript.append(message) # type: ignore
@when('I add message "{message}" to the second session')
def step_add_message_second(context: object, message: str) -> None:
"""Add a message to the second session."""
_setup_mock_app(context)
context.app._sessions[1].transcript.append(message) # type: ignore
@then('the first session transcript should contain "{message}"')
def step_check_first_transcript_contains(context: object, message: str) -> None:
"""Check that the first session transcript contains a message."""
_setup_mock_app(context)
assert message in context.app._sessions[0].transcript # type: ignore
@then('the first session transcript should not contain "{message}"')
def step_check_first_transcript_not_contains(context: object, message: str) -> None:
"""Check that the first session transcript does not contain a message."""
_setup_mock_app(context)
assert message not in context.app._sessions[0].transcript # type: ignore
@then('the second session transcript should contain "{message}"')
def step_check_second_transcript_contains(context: object, message: str) -> None:
"""Check that the second session transcript contains a message."""
_setup_mock_app(context)
assert message in context.app._sessions[1].transcript # type: ignore
@then('the second session transcript should not contain "{message}"')
def step_check_second_transcript_not_contains(context: object, message: str) -> None:
"""Check that the second session transcript does not contain a message."""
_setup_mock_app(context)
assert message not in context.app._sessions[1].transcript # type: ignore
@when("I create a new session")
def step_create_new_session(context: object) -> None:
"""Create a new session."""
import uuid
_setup_mock_app(context)
session_id = str(uuid.uuid4())[:8]
new_session = SessionView(
session_id=session_id,
transcript=[],
name=f"Session {len(context.app._sessions) + 1}", # type: ignore
created_at=datetime.utcnow().isoformat(),
)
context.app._sessions.append(new_session) # type: ignore
context.app._active_session_index = len(context.app._sessions) - 1 # type: ignore
context.new_session = new_session # type: ignore
@then("the new session should have a created_at timestamp in ISO format")
def step_check_created_at_timestamp(context: object) -> None:
"""Check that the new session has a valid ISO format timestamp."""
timestamp = context.new_session.created_at # type: ignore
# Try to parse it as ISO format
datetime.fromisoformat(timestamp)
-39
View File
@@ -1,39 +0,0 @@
"""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, (
f"Expected last persona '{persona_name}' but got '{last}'"
)
@@ -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 mock registry last persona should be set to "{expected}"')
@then('the 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)
@@ -1,68 +0,0 @@
@tdd_issue @tdd_issue_10491 @mock_only
Feature: TDD Issue #10491 — TUI BINDINGS missing alt+up and alt+down block cursor navigation keys
As a developer
I want to verify that the TUI app BINDINGS include alt+up and alt+down
and that action_cursor_up() and action_cursor_down() methods exist and work correctly
So that the bug is captured and will be caught by a regression test
# These scenarios verify that the TUI app correctly handles block cursor
# navigation via alt+up and alt+down key bindings. The @tdd_expected_fail
# tag inverts the result so CI passes while the bug is still present.
# When bug #10491 is fixed, the @tdd_expected_fail tag must be removed.
Background:
Given the TUI app module is imported with mocked Textual for cursor tests
And a mock command router and persona state for cursor tests
And the Textual TUI app is instantiated for cursor tests
@tdd_expected_fail
Scenario: alt+up binding is present in BINDINGS
Then the BINDINGS list should contain an entry for "alt+up"
@tdd_expected_fail
Scenario: alt+down binding is present in BINDINGS
Then the BINDINGS list should contain an entry for "alt+down"
@tdd_expected_fail
Scenario: alt+up binding maps to action_cursor_up
Then the BINDINGS entry for "alt+up" should map to action "cursor_up"
@tdd_expected_fail
Scenario: alt+down binding maps to action_cursor_down
Then the BINDINGS entry for "alt+down" should map to action "cursor_down"
@tdd_expected_fail
Scenario: action_cursor_up method exists on the TUI app
Then the TUI app should have an action_cursor_up method
@tdd_expected_fail
Scenario: action_cursor_down method exists on the TUI app
Then the TUI app should have an action_cursor_down method
@tdd_expected_fail
Scenario: pressing alt+up moves block cursor to previous conversation block
Given the TUI app has conversation blocks loaded
And the block cursor is positioned at index 2
When action_cursor_up is called on the TUI app
Then the block cursor index should be 1
@tdd_expected_fail
Scenario: pressing alt+down moves block cursor to next conversation block
Given the TUI app has conversation blocks loaded
And the block cursor is positioned at index 1
When action_cursor_down is called on the TUI app
Then the block cursor index should be 2
@tdd_expected_fail
Scenario: pressing alt+up at top of stream does not raise an error
Given the TUI app has conversation blocks loaded
And the block cursor is positioned at index 0
When action_cursor_up is called on the TUI app
Then the block cursor index should be 0
@tdd_expected_fail
Scenario: pressing alt+down at bottom of stream does not raise an error
Given the TUI app has conversation blocks loaded
And the block cursor is positioned at the last block
When action_cursor_down is called on the TUI app
Then the block cursor index should be at the last block
+2 -2
View File
@@ -37,7 +37,7 @@ Feature: TUI App Coverage
Scenario: The Textual TUI app can be instantiated with mocked Textual
Given a mock command router and persona state
When I instantiate the Textual TUI app
Then the app should have a default session with session_id "default"
Then the app should have a _session with session_id "default"
And the app should store the command router
And the app should store the persona state
@@ -47,7 +47,7 @@ Feature: TUI App Coverage
Given a mock command router and persona state
When I instantiate the Textual TUI app
Then the app class should have CSS_PATH set to "cleveragents.tcss"
And the app class should have 5 key bindings
And the app class should have 3 key bindings
# --- compose method (lines 102-112) ---
-72
View File
@@ -1,72 +0,0 @@
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
-51
View File
@@ -1,51 +0,0 @@
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"
+1 -1
View File
@@ -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 mock registry last persona should be set to "coder"
And the 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"
-2
View File
@@ -10,8 +10,6 @@ site_dir: build/site
nav:
- Specification: specification.md
- Guides:
- Installation and Setup: guides/installation-setup.md
- Architecture: architecture.md
- API Reference:
- Overview: api/index.md
@@ -37,14 +37,8 @@ from cleveragents.application.services.plan_execution_context import (
RuntimeExecuteActor,
RuntimeExecuteResult,
)
from cleveragents.core.exceptions import (
BudgetExceededError,
PlanBudgetExceededError,
PlanError,
ValidationError,
)
from cleveragents.core.exceptions import 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,
@@ -60,7 +54,6 @@ 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
@@ -328,8 +321,6 @@ 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.
@@ -377,8 +368,6 @@ 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")
@@ -926,97 +915,6 @@ 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,
+1 -15
View File
@@ -18,23 +18,9 @@ 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, web=web, web_port=web_port))
raise typer.Exit(run_tui(headless=headless))
-74
View File
@@ -293,78 +293,6 @@ 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.
@@ -400,7 +328,6 @@ class ExecutionError(CleverAgentsError):
__all__ = [
"AuthenticationError",
"AuthorizationError",
"BudgetExceededError",
"BusinessRuleViolation",
"CleverAgentsError",
"ConfigurationError",
@@ -418,7 +345,6 @@ __all__ = [
"ModelNotAvailableError",
"NetworkError",
"NotFoundError",
"PlanBudgetExceededError",
"PlanError",
"ProviderError",
"RateLimitError",
@@ -221,24 +221,6 @@ 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")
+26
View File
@@ -525,6 +525,32 @@ class ProviderRegistry:
**kwargs,
)
if provider_type == ProviderType.OPENROUTER:
from langchain_openai import ChatOpenAI
from cleveragents.providers.llm.openrouter_provider import (
OpenRouterChatProvider,
)
key_attr = self.PROVIDER_KEY_ATTRS.get(provider_type)
api_key = getattr(self._settings, key_attr, None) if key_attr else None
organization = getattr(self._settings, "openrouter_organization", None)
default_headers: dict[str, str] | None = kwargs.pop("default_headers", None)
if organization:
if default_headers is None:
default_headers = {}
default_headers.setdefault("HTTP-Referer", organization)
default_headers.setdefault("X-Title", organization)
openrouter_kwargs: dict[str, Any] = {
"model": model_id or "anthropic/claude-sonnet-4-20250514",
"openai_api_base": OpenRouterChatProvider._BASE_URL,
"openai_api_key": api_key or "",
}
if default_headers:
openrouter_kwargs["default_headers"] = default_headers
openrouter_kwargs.update(kwargs)
return ChatOpenAI(**openrouter_kwargs)
if provider_type == ProviderType.GROQ:
from langchain_groq import ChatGroq
+9 -105
View File
@@ -1,12 +1,10 @@
"""Textual TUI application shell - Multi-session support."""
"""Textual TUI application shell."""
from __future__ import annotations
import importlib
import os
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, ClassVar, Protocol
from cleveragents.tui.first_run import create_default_persona_for_actor, is_first_run
@@ -56,12 +54,10 @@ def textual_available() -> bool:
@dataclass(slots=True)
class SessionView:
"""Per-session TUI view model with independent A2A binding."""
"""Minimal per-session TUI view model."""
session_id: str
transcript: list[str] = field(default_factory=list)
name: str = "" # User-friendly session name
created_at: str = "" # ISO format timestamp
transcript: list[str]
class _CommandRouter(Protocol):
@@ -97,8 +93,6 @@ 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__(
@@ -110,80 +104,7 @@ if _TEXTUAL_AVAILABLE:
super().__init__()
self._command_router = command_router
self._persona_state = persona_state
# Initialize with default session
default_session = SessionView(
session_id="default",
transcript=[],
name="Default",
created_at=datetime.utcnow().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.utcnow().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.utcnow().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
self._session = SessionView(session_id="default", transcript=[])
def compose(self) -> Any:
yield _Header(show_clock=True)
@@ -228,28 +149,12 @@ if _TEXTUAL_AVAILABLE:
help_panel.toggle(context_name)
def action_cycle_preset(self) -> None:
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._persona_state.cycle_preset(self._session.session_id)
self._refresh_persona_bar()
def _refresh_persona_bar(self) -> None:
session = self._get_active_session()
persona = self._persona_state.active_persona(session.session_id)
preset = self._persona_state.current_preset(session.session_id)
persona = self._persona_state.active_persona(self._session.session_id)
preset = self._persona_state.current_preset(self._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)
@@ -268,10 +173,9 @@ 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=session.session_id
raw, session_id=self._session.session_id
),
shell_confirm=lambda _cmd: (
os.environ.get("CLEVERAGENTS_ALLOW_DANGEROUS_SHELL", "").strip()
+2 -143
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import contextlib
import json
from collections import defaultdict
from collections.abc import Callable
@@ -224,144 +223,8 @@ class TuiCommandRouter:
return f"Import failed: {exc}"
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 """<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CleverAgents TUI</title>
<style>
body {
margin: 0;
padding: 0;
font-family: monospace;
background-color: #1e1e1e;
color: #f8f8f2;
}
#tui-container {
width: 100%;
height: 100vh;
overflow: hidden;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-size: 18px;
}
</style>
</head>
<body>
<div id="tui-container">
<div class="loading">Loading CleverAgents TUI...</div>
</div>
<script>
// WebSocket connection to TUI app
// Note: This is a placeholder. Full implementation would require
// a WebSocket server in the TUI app to handle real-time rendering.
console.log("TUI Web mode loaded. WebSocket support coming soon.");
</script>
</body>
</html>"""
def _run_tui_web(app: CleverAgentsTuiApp, *, port: int = 8000) -> int: # type: ignore[valid-type]
"""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).
"""
def run_tui(*, headless: bool = False) -> int:
"""Run the Textual TUI app or a headless startup check."""
container = get_container()
registry = container.persona_registry()
state = container.persona_state(registry=registry)
@@ -380,9 +243,5 @@ def run_tui(*, headless: bool = False, web: bool = False, web_port: int = 8000)
return 0
app = CleverAgentsTuiApp(command_router=router, persona_state=state)
if web:
return _run_tui_web(app, port=web_port)
app.run()
return 0
+8 -10
View File
@@ -79,25 +79,23 @@ class PersonaRegistry:
return result
def resolve_export_path(self, output_path: Path) -> Path:
"""Resolve export path, accepting both absolute and relative paths."""
resolved = output_path.resolve()
# Allow absolute paths directly
if output_path.is_absolute():
return resolved
# For relative paths, ensure they stay within working directory
raise ValueError(
"Export path must be relative to current working directory"
)
base = Path.cwd().resolve()
resolved = (base / output_path).resolve()
if not resolved.is_relative_to(base):
raise ValueError("Export path must stay within working directory")
return resolved
def resolve_import_path(self, input_path: Path) -> Path:
"""Resolve import path, accepting both absolute and relative paths."""
resolved = input_path.resolve()
# Allow absolute paths directly
if input_path.is_absolute():
return resolved
# For relative paths, ensure they stay within working directory
raise ValueError(
"Import path must be relative to current working directory"
)
base = Path.cwd().resolve()
resolved = (base / input_path).resolve()
if not resolved.is_relative_to(base):
raise ValueError("Import path must stay within working directory")
return resolved
-26
View File
@@ -63,32 +63,6 @@ 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)
Submodule work/repo deleted from 435e409df9