Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5d187086c | |||
| 9a5ccc6b01 | |||
| f167098541 | |||
| 072f470212 | |||
| 1f95ea0c2a | |||
| 832d0b26ae | |||
| 89baa0a525 |
+8
-7
@@ -28,6 +28,14 @@ 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
|
||||
@@ -348,13 +356,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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
# Getting Started with CleverAgents
|
||||
|
||||
Welcome to CleverAgents! This guide will help you get up and running in about 5 minutes, walk you through your first project, and point you toward deeper learning resources.
|
||||
|
||||
## What is CleverAgents?
|
||||
|
||||
CleverAgents is a Python-first AI agent orchestration platform that lets you build, configure, and run intelligent automation workflows. It provides:
|
||||
|
||||
- **Unified CLI** (`agents` command) for all interactions
|
||||
- **Interactive TUI** (Terminal User Interface) for hands-on agent management
|
||||
- **Actor System** — composable AI agents with tools and skills
|
||||
- **Plan Lifecycle** — structured workflow from strategy to execution to application
|
||||
- **Resource Management** — handle files, databases, containers, and more
|
||||
- **Multi-Provider Support** — OpenAI, Anthropic, Google, Azure, and others
|
||||
|
||||
## Quick Start (5 Minutes)
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://git.cleverthis.com/cleveragents/cleveragents-core.git
|
||||
cd cleveragents-core
|
||||
```
|
||||
|
||||
### 2. Set Up Your Environment
|
||||
|
||||
```bash
|
||||
# Create a virtual environment
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
||||
|
||||
# Install CleverAgents with development dependencies
|
||||
pip install -e ".[dev,tests,docs]"
|
||||
|
||||
# Set up pre-commit hooks and verify tooling
|
||||
bash scripts/setup-dev.sh
|
||||
```
|
||||
|
||||
### 3. Verify Installation
|
||||
|
||||
```bash
|
||||
# Check the CLI is working
|
||||
agents --version
|
||||
agents --help
|
||||
|
||||
# Run diagnostics to check LLM provider configuration
|
||||
agents diagnostics
|
||||
```
|
||||
|
||||
### 4. Configure an LLM Provider
|
||||
|
||||
CleverAgents works with multiple LLM providers. Set up at least one:
|
||||
|
||||
**OpenAI:**
|
||||
```bash
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
```
|
||||
|
||||
**Anthropic:**
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||
```
|
||||
|
||||
**Google:**
|
||||
```bash
|
||||
export GOOGLE_API_KEY="..."
|
||||
```
|
||||
|
||||
See [LLM Provider Configuration](#llm-provider-configuration) below for all supported providers.
|
||||
|
||||
### 5. Launch the Interactive TUI
|
||||
|
||||
```bash
|
||||
# Install the TUI extra if not already installed
|
||||
pip install -e ".[tui]"
|
||||
|
||||
# Launch the interactive terminal UI
|
||||
agents tui
|
||||
```
|
||||
|
||||
Inside the TUI, you can:
|
||||
- Type messages and press `Enter` to chat with the active actor
|
||||
- Press `/` to open the slash command overlay
|
||||
- Press `@` to insert file/resource references
|
||||
- Press `!` to enter shell mode
|
||||
- Press `F1` for context-sensitive help
|
||||
- Press `Ctrl+Q` to quit
|
||||
|
||||
## Your First Project: Hello World Agent
|
||||
|
||||
Let's create a simple agent that greets you and answers questions.
|
||||
|
||||
### Step 1: Create a Basic Actor Configuration
|
||||
|
||||
Create a file `my-first-actor.yaml`:
|
||||
|
||||
```yaml
|
||||
# Simple greeting actor
|
||||
name: local/hello-world
|
||||
entry_node: greeter
|
||||
nodes:
|
||||
greeter:
|
||||
model: gpt-4o # or your preferred model
|
||||
tool_sources: [builtin]
|
||||
system_prompt: |
|
||||
You are a friendly greeting agent.
|
||||
Respond warmly and helpfully to user messages.
|
||||
Keep responses concise and friendly.
|
||||
```
|
||||
|
||||
### Step 2: Use the Actor in the CLI
|
||||
|
||||
```bash
|
||||
# Tell the agent to do something
|
||||
agents tell --actor local/hello-world "Say hello and tell me what you can do"
|
||||
|
||||
# Or use the v3 plan workflow
|
||||
agents plan use local/hello-world my-project
|
||||
agents plan execute <PLAN_ID>
|
||||
agents plan apply <PLAN_ID>
|
||||
```
|
||||
|
||||
### Step 3: Use the Actor in the TUI
|
||||
|
||||
```bash
|
||||
# Launch the TUI
|
||||
agents tui
|
||||
|
||||
# Inside the TUI:
|
||||
# 1. Press Ctrl+T to cycle through available actors
|
||||
# 2. Select "local/hello-world"
|
||||
# 3. Type a message and press Enter
|
||||
```
|
||||
|
||||
## Basic Concepts
|
||||
|
||||
### Actors
|
||||
|
||||
**Actors** are the execution units of CleverAgents. Each actor:
|
||||
- Is defined in YAML with a name, entry node, and node graph
|
||||
- Binds an LLM, tools, and optional integrations (LSP, MCP)
|
||||
- Can be built-in (e.g., `openai/gpt-4o`) or custom (e.g., `local/my-actor`)
|
||||
|
||||
Example actor structure:
|
||||
```yaml
|
||||
name: local/my-actor
|
||||
entry_node: main
|
||||
nodes:
|
||||
main:
|
||||
model: gpt-4o
|
||||
tool_sources: [builtin, mcp://bash-tools]
|
||||
```
|
||||
|
||||
See [Actor System](../architecture.md#actor-system) for details.
|
||||
|
||||
### Tools
|
||||
|
||||
**Tools** are atomic capabilities available to actors. They include:
|
||||
- Built-in tools (file operations, shell commands)
|
||||
- MCP (Model Context Protocol) tools from external servers
|
||||
- LSP (Language Server Protocol) tools for code intelligence
|
||||
- Custom tools defined in your project
|
||||
|
||||
Tools are registered in the `ToolRegistry` and invoked by actors during execution.
|
||||
|
||||
### Skills
|
||||
|
||||
**Skills** are composable capability bundles that expose one or more tools. They:
|
||||
- Load from YAML or AgentSkills.io-compatible directories
|
||||
- Support progressive disclosure (discover → activate → deactivate)
|
||||
- Are tracked by the `SkillRegistry`
|
||||
|
||||
### Resources
|
||||
|
||||
**Resources** are managed external entities like files, databases, and containers. They:
|
||||
- Organize into a DAG (Directed Acyclic Graph) with dependency tracking
|
||||
- Support multiple types: `file`, `directory`, `sqlite`, `postgresql`, `container.docker`, etc.
|
||||
- Each type has a handler implementing CRUD, checkpoint, and rollback
|
||||
|
||||
### Plan Lifecycle
|
||||
|
||||
The **Plan Lifecycle** is the central workflow abstraction:
|
||||
|
||||
```
|
||||
Action → Strategize → Execute → Apply
|
||||
↑ ↓
|
||||
└──────────────────────┘
|
||||
(correction/rollback)
|
||||
```
|
||||
|
||||
| Phase | Description |
|
||||
|-------|-------------|
|
||||
| **Action** | User intent captured as a plan request |
|
||||
| **Strategize** | LLM generates a structured plan with operations |
|
||||
| **Execute** | Operations executed against resources via tools |
|
||||
| **Apply** | Validated changes committed; diff reviewed and approved |
|
||||
|
||||
See [Plan Lifecycle](../architecture.md#plan-lifecycle) for details.
|
||||
|
||||
### Personas
|
||||
|
||||
**Personas** are named identities that bind:
|
||||
- An actor (which LLM and tools to use)
|
||||
- Argument presets (default parameters)
|
||||
- Scope references (which resources are available)
|
||||
|
||||
Personas are persisted in `~/.config/cleveragents/personas/` and can be switched in the TUI with `Ctrl+T`.
|
||||
|
||||
### Sessions
|
||||
|
||||
**Sessions** are conversation histories. You can:
|
||||
- Create new sessions: `agents session create --actor openai/gpt-4o`
|
||||
- List sessions: `agents session list`
|
||||
- Export sessions: `agents session export --session-id <ID> --output session.json`
|
||||
- Import sessions: `agents session import --input session.json`
|
||||
|
||||
## LLM Provider Configuration
|
||||
|
||||
CleverAgents automatically discovers and uses configured LLM providers. Set environment variables for the providers you want to use:
|
||||
|
||||
| Provider | Environment Variable | Example |
|
||||
|----------|----------------------|---------|
|
||||
| OpenAI | `OPENAI_API_KEY` | `sk-...` |
|
||||
| Anthropic | `ANTHROPIC_API_KEY` | `sk-ant-...` |
|
||||
| Google | `GOOGLE_API_KEY` or `GOOGLE_GENAI_API_KEY` | `AIza...` |
|
||||
| Azure OpenAI | `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_DEPLOYMENT` | See Azure docs |
|
||||
| OpenRouter | `OPENROUTER_API_KEY` | `sk-or-...` |
|
||||
| Groq | `GROQ_API_KEY` | `gsk_...` |
|
||||
| Together | `TOGETHER_API_KEY` | `...` |
|
||||
| Cohere | `COHERE_API_KEY` | `...` |
|
||||
|
||||
### Setting a Default Provider
|
||||
|
||||
```bash
|
||||
# Pin the global provider
|
||||
export CLEVERAGENTS_DEFAULT_PROVIDER=openai
|
||||
|
||||
# Pin a specific model
|
||||
export CLEVERAGENTS_DEFAULT_MODEL=gpt-4o
|
||||
```
|
||||
|
||||
### Checking Your Configuration
|
||||
|
||||
```bash
|
||||
# See which providers are configured and which actor is selected
|
||||
agents diagnostics
|
||||
```
|
||||
|
||||
## Common First-Time Issues and Solutions
|
||||
|
||||
### Issue: "No LLM provider configured"
|
||||
|
||||
**Symptom:** Error message says no API keys found.
|
||||
|
||||
**Solution:**
|
||||
1. Verify you've set an environment variable: `echo $OPENAI_API_KEY`
|
||||
2. If empty, set it: `export OPENAI_API_KEY="sk-..."`
|
||||
3. Run `agents diagnostics` to verify the provider is detected
|
||||
4. Restart your terminal or shell session if you just set the variable
|
||||
|
||||
### Issue: "Actor not found"
|
||||
|
||||
**Symptom:** Error says `local/my-actor` doesn't exist.
|
||||
|
||||
**Solution:**
|
||||
1. Check the actor file exists: `ls my-first-actor.yaml`
|
||||
2. Verify the file is valid YAML (check indentation)
|
||||
3. Use the full path if the file is not in the current directory
|
||||
4. Built-in actors use the format `<provider>/<model>` (e.g., `openai/gpt-4o`)
|
||||
|
||||
### Issue: "TUI won't start"
|
||||
|
||||
**Symptom:** `agents tui` fails or shows a blank screen.
|
||||
|
||||
**Solution:**
|
||||
1. Ensure the TUI extra is installed: `pip install -e ".[tui]"`
|
||||
2. Check your terminal supports 256 colors: `echo $TERM`
|
||||
3. Try running with explicit terminal: `TERM=xterm-256color agents tui`
|
||||
4. Check for conflicting environment variables: `env | grep -i textual`
|
||||
|
||||
### Issue: "Tool execution fails"
|
||||
|
||||
**Symptom:** Actor tries to use a tool but gets an error.
|
||||
|
||||
**Solution:**
|
||||
1. Check the tool is available: `agents tools list` (if implemented)
|
||||
2. Verify tool permissions are granted (TUI shows permission overlay)
|
||||
3. Check tool configuration in the actor YAML
|
||||
4. Review tool documentation: see [Tool System](../architecture.md#tool-system)
|
||||
|
||||
### Issue: "Session not found"
|
||||
|
||||
**Symptom:** Error when trying to export or import a session.
|
||||
|
||||
**Solution:**
|
||||
1. List available sessions: `agents session list`
|
||||
2. Use the correct session ID from the list
|
||||
3. Check the session file exists (for import): `ls session.json`
|
||||
4. Verify the JSON is valid: `python -m json.tool session.json`
|
||||
|
||||
### Issue: "Permission denied" errors
|
||||
|
||||
**Symptom:** Actor can't read/write files or access resources.
|
||||
|
||||
**Solution:**
|
||||
1. Check file permissions: `ls -la <file>`
|
||||
2. Ensure the file is readable/writable by your user
|
||||
3. In the TUI, approve permission requests when prompted (press `y`)
|
||||
4. Check resource configuration in your project
|
||||
|
||||
## Next Learning Steps
|
||||
|
||||
Now that you're up and running, here's what to explore next:
|
||||
|
||||
### 1. **Understand the Architecture** (30 minutes)
|
||||
- Read [Architecture Overview](../architecture.md)
|
||||
- Learn about the layered design and key components
|
||||
- Understand the Plan Lifecycle in detail
|
||||
|
||||
### 2. **Build Your First Custom Actor** (1 hour)
|
||||
- Create a YAML actor configuration
|
||||
- Add tools and integrations
|
||||
- Test in the TUI and CLI
|
||||
- See [Actor System](../architecture.md#actor-system) for details
|
||||
|
||||
### 3. **Work with Resources** (1 hour)
|
||||
- Create file and database resources
|
||||
- Use resources in your actor
|
||||
- Understand the Resource DAG
|
||||
- See [Resource System](../architecture.md#resource-system)
|
||||
|
||||
### 4. **Explore Tools and Skills** (1 hour)
|
||||
- Discover available tools
|
||||
- Create custom tools
|
||||
- Load and manage skills
|
||||
- See [Tool System](../architecture.md#tool-system) and [Skill System](../architecture.md#skill-system)
|
||||
|
||||
### 5. **Master the Plan Lifecycle** (2 hours)
|
||||
- Create and execute plans
|
||||
- Understand phase transitions
|
||||
- Use correction and rollback
|
||||
- See [Plan Lifecycle](../architecture.md#plan-lifecycle)
|
||||
|
||||
### 6. **Integrate with External Services** (2 hours)
|
||||
- Set up MCP (Model Context Protocol) servers
|
||||
- Configure LSP (Language Server Protocol) integration
|
||||
- Use ACMS (Advanced Context Management System)
|
||||
- See [MCP Integration](../architecture.md#mcp-integration) and [LSP Integration](../architecture.md#lsp-integration)
|
||||
|
||||
### 7. **Deploy to Production** (2 hours)
|
||||
- Use server mode: `agents server connect`
|
||||
- Deploy with Kubernetes (see `k8s/`)
|
||||
- Configure observability and logging
|
||||
- See [Server Architecture](../development/agent-system-specification.md)
|
||||
|
||||
## Key Documentation References
|
||||
|
||||
| Topic | Document |
|
||||
|-------|----------|
|
||||
| **Architecture** | [Architecture Overview](../architecture.md) |
|
||||
| **Specification** | [Full Specification](../specification.md) |
|
||||
| **API Reference** | [API Docs](../api/index.md) |
|
||||
| **Development** | [Development Guide](../development/agent-system-specification.md) |
|
||||
| **Testing** | [Testing Guide](../development/testing.md) |
|
||||
| **FAQ** | [Frequently Asked Questions](../faq.md) |
|
||||
| **Design Decisions** | [Architecture Decision Records (ADRs)](../adr/index.md) |
|
||||
|
||||
## Tips for Success
|
||||
|
||||
1. **Start Small** — Create simple actors before complex ones
|
||||
2. **Use the TUI** — The interactive interface is great for learning and debugging
|
||||
3. **Read the Specification** — `docs/specification.md` is the authoritative source
|
||||
4. **Check the Examples** — See `examples/` for real-world configurations
|
||||
5. **Run Tests** — Use `nox -s unit_tests` to verify your changes
|
||||
6. **Ask for Help** — Check the FAQ and ADRs for common questions
|
||||
|
||||
## Troubleshooting Commands
|
||||
|
||||
```bash
|
||||
# Check your setup
|
||||
agents diagnostics
|
||||
|
||||
# List available actors
|
||||
agents actor list
|
||||
|
||||
# List available tools
|
||||
agents tools list # if implemented
|
||||
|
||||
# List sessions
|
||||
agents session list
|
||||
|
||||
# View help for any command
|
||||
agents <command> --help
|
||||
|
||||
# Run tests to verify everything works
|
||||
nox -s unit_tests
|
||||
|
||||
# Check code quality
|
||||
nox -s lint
|
||||
nox -s typecheck
|
||||
```
|
||||
|
||||
## What's Next?
|
||||
|
||||
- **Build an actor** — Create a custom actor for your use case
|
||||
- **Explore the TUI** — Spend time in the interactive interface
|
||||
- **Read the architecture** — Understand the design decisions
|
||||
- **Join the community** — Check out discussions and issues
|
||||
- **Contribute** — See [CONTRIBUTING.md](../../CONTRIBUTING.md) for guidelines
|
||||
|
||||
Happy automating! 🚀
|
||||
@@ -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
@@ -21,27 +21,6 @@
|
||||
"generated_by": "uat-tester",
|
||||
"generated_at": "2026-04-07"
|
||||
},
|
||||
{
|
||||
"title": "REPL and Actor Run: Interactive AI Sessions from the Terminal",
|
||||
"category": "cli-tools",
|
||||
"path": "cli-tools/repl-and-actor-run.md",
|
||||
"feature": "Interactive REPL and single-shot actor run commands",
|
||||
"commands": [
|
||||
"agents repl",
|
||||
"agents repl --help",
|
||||
"agents actor run openai/gpt-4o \"Say hello\"",
|
||||
"agents actor run --help",
|
||||
"agents actor run local/demo-assistant \"What is the capital of France?\" --config my-assistant.yaml",
|
||||
"agents actor run local/demo-assistant \"What is the capital of France?\" --config my-assistant.yaml --output answer.txt",
|
||||
"agents actor run local/demo-assistant \"Write a haiku about mountains\" --config my-assistant.yaml --temperature 0.9",
|
||||
"agents actor run local/demo-assistant \"I'm working on a Python project\" --config my-assistant.yaml --context my-session --context-dir /tmp/my-contexts",
|
||||
"agents actor run local/demo-assistant \"What are best practices for error handling?\" --config my-assistant.yaml --context my-session --context-dir /tmp/my-contexts"
|
||||
],
|
||||
"complexity": "intermediate",
|
||||
"educational_value": "high",
|
||||
"generated_by": "uat-tester",
|
||||
"generated_at": "2026-04-07"
|
||||
},
|
||||
{
|
||||
"title": "Managing AI Actors with the CleverAgents CLI",
|
||||
"category": "cli-tools",
|
||||
|
||||
+26
-5502
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
|
||||
@@ -1,80 +0,0 @@
|
||||
Feature: REPL and Actor Run CLI Showcase Documentation
|
||||
The showcase documentation for REPL and actor run CLI commands
|
||||
should be properly registered and accessible.
|
||||
|
||||
Scenario: Showcase markdown file exists
|
||||
Given the showcase directory exists
|
||||
When I check for the REPL and actor run showcase file
|
||||
Then the file should exist at "docs/showcase/cli-tools/repl-and-actor-run.md"
|
||||
|
||||
Scenario: Showcase is registered in examples.json
|
||||
Given the examples.json file exists
|
||||
When I check for the REPL and actor run showcase entry
|
||||
Then the entry should have title "REPL and Actor Run: Interactive AI Sessions from the Terminal"
|
||||
And the entry should have category "cli-tools"
|
||||
And the entry should have path "cli-tools/repl-and-actor-run.md"
|
||||
And the entry should have complexity "intermediate"
|
||||
|
||||
Scenario: Showcase has required commands documented
|
||||
Given the showcase markdown file exists
|
||||
When I check the documented commands
|
||||
Then the file should contain "agents repl"
|
||||
And the file should contain "agents actor run"
|
||||
And the file should contain "agents repl --help"
|
||||
And the file should contain "agents actor run --help"
|
||||
|
||||
Scenario: Showcase has REPL section
|
||||
Given the showcase markdown file exists
|
||||
When I check the content structure
|
||||
Then the file should contain "Part 1: The Interactive REPL"
|
||||
And the file should contain ":help"
|
||||
And the file should contain ":exit"
|
||||
And the file should contain "!!"
|
||||
|
||||
Scenario: Showcase has Actor Run section
|
||||
Given the showcase markdown file exists
|
||||
When I check the content structure
|
||||
Then the file should contain "Part 2: Single-Shot Actor Run"
|
||||
And the file should contain "--config"
|
||||
And the file should contain "--output"
|
||||
And the file should contain "--temperature"
|
||||
And the file should contain "--context"
|
||||
|
||||
Scenario: Showcase has real output examples
|
||||
Given the showcase markdown file exists
|
||||
When I check for verified output
|
||||
Then the file should contain "Actual Output:"
|
||||
And the file should contain "verified"
|
||||
|
||||
Scenario: Examples.json has valid JSON structure
|
||||
Given the examples.json file exists
|
||||
When I parse the JSON
|
||||
Then the JSON should be valid
|
||||
And the JSON should have "examples" key
|
||||
And the JSON should have "categories" key
|
||||
|
||||
Scenario: Showcase entry has all required fields
|
||||
Given the examples.json file exists
|
||||
When I check the REPL and actor run entry
|
||||
Then the entry should have "title" field
|
||||
And the entry should have "category" field
|
||||
And the entry should have "path" field
|
||||
And the entry should have "feature" field
|
||||
And the entry should have "commands" field
|
||||
And the entry should have "complexity" field
|
||||
And the entry should have "educational_value" field
|
||||
|
||||
Scenario: Showcase commands list is not empty
|
||||
Given the examples.json file exists
|
||||
When I check the REPL and actor run entry
|
||||
Then the commands list should have at least 5 items
|
||||
And the commands should include "agents repl"
|
||||
And the commands should include "agents actor run"
|
||||
|
||||
Scenario: Showcase file has proper markdown formatting
|
||||
Given the showcase markdown file exists
|
||||
When I check the markdown structure
|
||||
Then the file should start with a heading
|
||||
And the file should have multiple sections
|
||||
And the file should have code blocks
|
||||
And the file should have proper link formatting
|
||||
@@ -1,603 +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.plan_executor import PlanExecutor
|
||||
from cleveragents.core.exceptions import (
|
||||
BudgetExceededError,
|
||||
PlanBudgetExceededError,
|
||||
PlanError,
|
||||
)
|
||||
from cleveragents.domain.models.core.automation_profile import AutomationProfile
|
||||
from cleveragents.domain.models.core.cost_metadata import CostMetadata
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
PlanPhase,
|
||||
PlanTimestamps,
|
||||
ProcessingState,
|
||||
)
|
||||
from cleveragents.providers.cost_tracker import CostTracker
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BUDGET_PLAN_ID = "01KBUDGET0PLAN000000000001"
|
||||
_BUDGET_ROOT_ID = "01KBUDGET0ROOT000000000001"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_budget_plan(
|
||||
*,
|
||||
phase: PlanPhase = PlanPhase.EXECUTE,
|
||||
state: ProcessingState = ProcessingState.QUEUED,
|
||||
definition_of_done: str = "Implement feature",
|
||||
decision_root_id: str = _BUDGET_ROOT_ID,
|
||||
) -> MagicMock:
|
||||
"""Build a mock plan for budget enforcement tests."""
|
||||
plan = MagicMock()
|
||||
plan.phase = phase
|
||||
plan.state = state
|
||||
plan.definition_of_done = definition_of_done
|
||||
plan.decision_root_id = decision_root_id
|
||||
plan.invariants = []
|
||||
plan.timestamps = PlanTimestamps()
|
||||
plan.changeset_id = None
|
||||
plan.sandbox_refs = []
|
||||
plan.error_details = None
|
||||
plan.read_only = False
|
||||
plan.project_links = []
|
||||
plan.subplan_statuses = []
|
||||
plan.identity = MagicMock()
|
||||
plan.identity.plan_id = _BUDGET_PLAN_ID
|
||||
return plan
|
||||
|
||||
|
||||
def _make_budget_lifecycle(plan: Any | None = None) -> MagicMock:
|
||||
"""Build a mock lifecycle service for budget tests."""
|
||||
lcs = MagicMock()
|
||||
if plan is not None:
|
||||
lcs.get_plan.return_value = plan
|
||||
lcs.start_execute = MagicMock()
|
||||
lcs.complete_execute = MagicMock()
|
||||
lcs.fail_execute = MagicMock()
|
||||
lcs._commit_plan = MagicMock()
|
||||
return lcs
|
||||
|
||||
|
||||
def _make_cost_tracker_with_plan_budget(budget: float) -> CostTracker:
|
||||
"""Create a CostTracker with a specific plan budget."""
|
||||
return CostTracker(budget_per_plan=budget)
|
||||
|
||||
|
||||
def _make_cost_tracker_with_daily_budget(budget: float) -> CostTracker:
|
||||
"""Create a CostTracker with a specific daily budget."""
|
||||
return CostTracker(budget_per_day=budget)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exception creation steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when(
|
||||
'I create a BudgetExceededError with plan_id "{pid}" budget_type "{btype}" used {used:f} limit {limit:f}'
|
||||
)
|
||||
def step_create_budget_exceeded_error(
|
||||
context: Context, pid: str, btype: str, used: float, limit: float
|
||||
) -> None:
|
||||
"""Create a BudgetExceededError with given attributes."""
|
||||
context.budget_exc = BudgetExceededError(
|
||||
f"Budget exceeded: {used} >= {limit}",
|
||||
plan_id=pid,
|
||||
budget_type=btype,
|
||||
used=used,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@then('the BudgetExceededError plan_id should be "{expected}"')
|
||||
def step_check_budget_exc_plan_id(context: Context, expected: str) -> None:
|
||||
"""Verify BudgetExceededError plan_id."""
|
||||
assert context.budget_exc.plan_id == expected, (
|
||||
f"Expected plan_id={expected!r}, got {context.budget_exc.plan_id!r}"
|
||||
)
|
||||
|
||||
|
||||
@then('the BudgetExceededError budget_type should be "{expected}"')
|
||||
def step_check_budget_exc_budget_type(context: Context, expected: str) -> None:
|
||||
"""Verify BudgetExceededError budget_type."""
|
||||
assert context.budget_exc.budget_type == expected, (
|
||||
f"Expected budget_type={expected!r}, got {context.budget_exc.budget_type!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the BudgetExceededError used should be {expected:f}")
|
||||
def step_check_budget_exc_used(context: Context, expected: float) -> None:
|
||||
"""Verify BudgetExceededError used."""
|
||||
assert context.budget_exc.used == expected, (
|
||||
f"Expected used={expected}, 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."""
|
||||
assert text in str(context.budget_exc), (
|
||||
f"Expected '{text}' in '{context.budget_exc}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the BudgetExceededError should be an instance of PlanError")
|
||||
def step_check_budget_exc_is_plan_error(context: Context) -> None:
|
||||
"""Verify BudgetExceededError is a PlanError."""
|
||||
assert isinstance(context.budget_exc, PlanError), (
|
||||
f"Expected PlanError, got {type(context.budget_exc).__name__}"
|
||||
)
|
||||
|
||||
|
||||
@when(
|
||||
'I create a PlanBudgetExceededError with plan_id "{pid}" used {used:f} limit {limit:f}'
|
||||
)
|
||||
def step_create_plan_budget_exceeded_error(
|
||||
context: Context, pid: str, used: float, limit: float
|
||||
) -> None:
|
||||
"""Create a PlanBudgetExceededError with given attributes."""
|
||||
context.plan_budget_exc = PlanBudgetExceededError(
|
||||
f"Plan budget exceeded: {used} >= {limit}",
|
||||
plan_id=pid,
|
||||
used=used,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@then('the PlanBudgetExceededError plan_id should be "{expected}"')
|
||||
def step_check_plan_budget_exc_plan_id(context: Context, expected: str) -> None:
|
||||
"""Verify PlanBudgetExceededError plan_id."""
|
||||
assert context.plan_budget_exc.plan_id == expected, (
|
||||
f"Expected plan_id={expected!r}, got {context.plan_budget_exc.plan_id!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the PlanBudgetExceededError used should be {expected:f}")
|
||||
def step_check_plan_budget_exc_used(context: Context, expected: float) -> None:
|
||||
"""Verify PlanBudgetExceededError used."""
|
||||
assert context.plan_budget_exc.used == expected, (
|
||||
f"Expected used={expected}, got {context.plan_budget_exc.used}"
|
||||
)
|
||||
|
||||
|
||||
@then("the PlanBudgetExceededError limit should be {expected:f}")
|
||||
def step_check_plan_budget_exc_limit(context: Context, expected: float) -> None:
|
||||
"""Verify PlanBudgetExceededError limit."""
|
||||
assert context.plan_budget_exc.limit == expected, (
|
||||
f"Expected limit={expected}, got {context.plan_budget_exc.limit}"
|
||||
)
|
||||
|
||||
|
||||
@then('the PlanBudgetExceededError message should contain "{text}"')
|
||||
def step_check_plan_budget_exc_message(context: Context, text: str) -> None:
|
||||
"""Verify PlanBudgetExceededError message contains text."""
|
||||
assert text in str(context.plan_budget_exc), (
|
||||
f"Expected '{text}' in '{context.plan_budget_exc}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the PlanBudgetExceededError should be an instance of PlanError")
|
||||
def step_check_plan_budget_exc_is_plan_error(context: Context) -> None:
|
||||
"""Verify PlanBudgetExceededError is a PlanError."""
|
||||
assert isinstance(context.plan_budget_exc, PlanError), (
|
||||
f"Expected PlanError, got {type(context.plan_budget_exc).__name__}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PlanExecutor setup steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a budget enforcement PlanExecutor without cost_tracker")
|
||||
def step_budget_executor_no_tracker(context: Context) -> None:
|
||||
"""Create a PlanExecutor without a cost tracker."""
|
||||
plan = _make_budget_plan()
|
||||
context.budget_lifecycle = _make_budget_lifecycle(plan)
|
||||
context.budget_plan = plan
|
||||
context.budget_plan_id = _BUDGET_PLAN_ID
|
||||
context.budget_executor = PlanExecutor(
|
||||
lifecycle_service=context.budget_lifecycle,
|
||||
cost_tracker=None,
|
||||
)
|
||||
|
||||
|
||||
@given("a budget enforcement plan in Execute-Queued state")
|
||||
def step_budget_plan_execute_queued(context: Context) -> None:
|
||||
"""Set up a plan in Execute-Queued state (already done in executor setup)."""
|
||||
|
||||
|
||||
@given("a budget enforcement PlanExecutor with plan budget {budget:f}")
|
||||
def step_budget_executor_with_plan_budget(context: Context, budget: float) -> None:
|
||||
"""Create a PlanExecutor with a plan budget limit."""
|
||||
plan = _make_budget_plan()
|
||||
context.budget_lifecycle = _make_budget_lifecycle(plan)
|
||||
context.budget_plan = plan
|
||||
context.budget_plan_id = _BUDGET_PLAN_ID
|
||||
context.budget_cost_tracker = _make_cost_tracker_with_plan_budget(budget)
|
||||
context.budget_cost_metadata = CostMetadata()
|
||||
context.budget_executor = PlanExecutor(
|
||||
lifecycle_service=context.budget_lifecycle,
|
||||
cost_tracker=context.budget_cost_tracker,
|
||||
cost_metadata=context.budget_cost_metadata,
|
||||
)
|
||||
|
||||
|
||||
@given("a budget enforcement PlanExecutor with daily budget {budget:f}")
|
||||
def step_budget_executor_with_daily_budget(context: Context, budget: float) -> None:
|
||||
"""Create a PlanExecutor with a daily budget limit."""
|
||||
plan = _make_budget_plan()
|
||||
context.budget_lifecycle = _make_budget_lifecycle(plan)
|
||||
context.budget_plan = plan
|
||||
context.budget_plan_id = _BUDGET_PLAN_ID
|
||||
context.budget_cost_tracker = _make_cost_tracker_with_daily_budget(budget)
|
||||
context.budget_cost_metadata = CostMetadata()
|
||||
context.budget_executor = PlanExecutor(
|
||||
lifecycle_service=context.budget_lifecycle,
|
||||
cost_tracker=context.budget_cost_tracker,
|
||||
cost_metadata=context.budget_cost_metadata,
|
||||
)
|
||||
|
||||
|
||||
@given(
|
||||
"a budget enforcement PlanExecutor with plan budget {budget:f} and no cost_metadata"
|
||||
)
|
||||
def step_budget_executor_plan_budget_no_metadata(
|
||||
context: Context, budget: float
|
||||
) -> None:
|
||||
"""Create a PlanExecutor with plan budget but no cost_metadata."""
|
||||
plan = _make_budget_plan()
|
||||
context.budget_lifecycle = _make_budget_lifecycle(plan)
|
||||
context.budget_plan = plan
|
||||
context.budget_plan_id = _BUDGET_PLAN_ID
|
||||
context.budget_cost_tracker = _make_cost_tracker_with_plan_budget(budget)
|
||||
context.budget_executor = PlanExecutor(
|
||||
lifecycle_service=context.budget_lifecycle,
|
||||
cost_tracker=context.budget_cost_tracker,
|
||||
cost_metadata=None,
|
||||
)
|
||||
|
||||
|
||||
@given("a budget enforcement PlanExecutor with failing lifecycle")
|
||||
def step_budget_executor_failing_lifecycle(context: Context) -> None:
|
||||
"""Create a PlanExecutor with a lifecycle that raises on get_plan."""
|
||||
lcs = MagicMock()
|
||||
lcs.get_plan.side_effect = RuntimeError("lifecycle failure")
|
||||
lcs._commit_plan = MagicMock()
|
||||
context.budget_lifecycle = lcs
|
||||
context.budget_plan_id = _BUDGET_PLAN_ID
|
||||
context.budget_executor = PlanExecutor(
|
||||
lifecycle_service=lcs,
|
||||
cost_tracker=None,
|
||||
)
|
||||
|
||||
|
||||
@given("the cost_metadata has total_cost {cost:f}")
|
||||
def step_set_cost_metadata_total_cost(context: Context, cost: float) -> None:
|
||||
"""Set the cost_metadata total_cost."""
|
||||
context.budget_cost_metadata.total_cost = cost
|
||||
|
||||
|
||||
@given("the daily spend is {spend:f}")
|
||||
def step_set_daily_spend(context: Context, spend: float) -> None:
|
||||
"""Set the daily spend by recording usage."""
|
||||
today_key = date.today().isoformat()
|
||||
with context.budget_cost_tracker._daily_costs_lock:
|
||||
context.budget_cost_tracker._daily_costs[today_key] = spend
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Execute steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I run budget enforcement execute")
|
||||
def step_run_budget_execute(context: Context) -> None:
|
||||
"""Run the execute phase."""
|
||||
try:
|
||||
context.budget_exec_result = context.budget_executor.run_execute(
|
||||
context.budget_plan_id
|
||||
)
|
||||
context.budget_raised = None
|
||||
except Exception as exc:
|
||||
context.budget_raised = exc
|
||||
|
||||
|
||||
@when("I run budget enforcement execute expecting budget error")
|
||||
def step_run_budget_execute_expect_error(context: Context) -> None:
|
||||
"""Run the execute phase expecting a budget error."""
|
||||
try:
|
||||
context.budget_exec_result = context.budget_executor.run_execute(
|
||||
context.budget_plan_id
|
||||
)
|
||||
context.budget_raised = None
|
||||
except (BudgetExceededError, PlanBudgetExceededError) as exc:
|
||||
context.budget_raised = exc
|
||||
except Exception as exc:
|
||||
context.budget_raised = exc
|
||||
|
||||
|
||||
@then("the budget enforcement execute should succeed without budget error")
|
||||
def step_budget_execute_success(context: Context) -> None:
|
||||
"""Verify execute succeeded without budget error."""
|
||||
if context.budget_raised is not None and isinstance(
|
||||
context.budget_raised, (BudgetExceededError, PlanBudgetExceededError)
|
||||
):
|
||||
raise AssertionError(
|
||||
f"Expected no budget error, got {type(context.budget_raised).__name__}: "
|
||||
f"{context.budget_raised}"
|
||||
)
|
||||
|
||||
|
||||
@then("a PlanBudgetExceededError should be raised")
|
||||
def step_check_plan_budget_error_raised(context: Context) -> None:
|
||||
"""Verify PlanBudgetExceededError was raised."""
|
||||
assert context.budget_raised is not None, (
|
||||
"Expected PlanBudgetExceededError but none was raised"
|
||||
)
|
||||
assert isinstance(context.budget_raised, PlanBudgetExceededError), (
|
||||
f"Expected PlanBudgetExceededError, got {type(context.budget_raised).__name__}: "
|
||||
f"{context.budget_raised}"
|
||||
)
|
||||
|
||||
|
||||
@then("the PlanBudgetExceededError plan_id should match the plan")
|
||||
def step_check_plan_budget_error_plan_id(context: Context) -> None:
|
||||
"""Verify PlanBudgetExceededError has the correct plan_id."""
|
||||
assert isinstance(context.budget_raised, PlanBudgetExceededError)
|
||||
assert context.budget_raised.plan_id == context.budget_plan_id, (
|
||||
f"Expected plan_id={context.budget_plan_id!r}, "
|
||||
f"got {context.budget_raised.plan_id!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("a BudgetExceededError should be raised")
|
||||
def step_check_budget_error_raised(context: Context) -> None:
|
||||
"""Verify BudgetExceededError was raised."""
|
||||
assert context.budget_raised is not None, (
|
||||
"Expected BudgetExceededError but none was raised"
|
||||
)
|
||||
assert isinstance(context.budget_raised, BudgetExceededError), (
|
||||
f"Expected BudgetExceededError, got {type(context.budget_raised).__name__}: "
|
||||
f"{context.budget_raised}"
|
||||
)
|
||||
|
||||
|
||||
@then("the lifecycle _commit_plan should have been called with budget_halt details")
|
||||
def step_check_commit_plan_budget_halt(context: Context) -> None:
|
||||
"""Verify _commit_plan was called with budget_halt in error_details."""
|
||||
assert context.budget_lifecycle._commit_plan.called, (
|
||||
"Expected _commit_plan to be called"
|
||||
)
|
||||
plan = context.budget_lifecycle.get_plan.return_value
|
||||
if isinstance(plan.error_details, dict):
|
||||
assert "budget_halt" in plan.error_details, (
|
||||
f"Expected 'budget_halt' in error_details, got {plan.error_details}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _check_budget direct call steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I call _check_budget directly with plan_id "{plan_id}"')
|
||||
def step_call_check_budget_directly(context: Context, plan_id: str) -> None:
|
||||
"""Call _check_budget directly."""
|
||||
try:
|
||||
context.budget_executor._check_budget(plan_id)
|
||||
context.budget_raised = None
|
||||
except (BudgetExceededError, PlanBudgetExceededError) as exc:
|
||||
context.budget_raised = exc
|
||||
except Exception as exc:
|
||||
context.budget_raised = exc
|
||||
|
||||
|
||||
@then("no budget exception should be raised")
|
||||
def step_no_budget_exception(context: Context) -> None:
|
||||
"""Verify no budget exception was raised."""
|
||||
if isinstance(
|
||||
context.budget_raised, (BudgetExceededError, PlanBudgetExceededError)
|
||||
):
|
||||
raise AssertionError(
|
||||
f"Expected no budget exception, got {type(context.budget_raised).__name__}: "
|
||||
f"{context.budget_raised}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _save_plan_state_on_budget_halt steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when(
|
||||
'I call _save_plan_state_on_budget_halt with plan_id "{plan_id}" budget_type "{btype}" used {used:f} limit {limit:f}'
|
||||
)
|
||||
def step_call_save_plan_state(
|
||||
context: Context, plan_id: str, btype: str, used: float, limit: float
|
||||
) -> None:
|
||||
"""Call _save_plan_state_on_budget_halt directly."""
|
||||
plan = _make_budget_plan()
|
||||
context.budget_lifecycle.get_plan.return_value = plan
|
||||
context.budget_plan = plan
|
||||
try:
|
||||
context.budget_executor._save_plan_state_on_budget_halt(
|
||||
plan_id=plan_id,
|
||||
budget_type=btype,
|
||||
used=used,
|
||||
limit=limit,
|
||||
)
|
||||
context.budget_raised = None
|
||||
except Exception as exc:
|
||||
context.budget_raised = exc
|
||||
|
||||
|
||||
@then("the lifecycle _commit_plan should have been called")
|
||||
def step_check_commit_plan_called(context: Context) -> None:
|
||||
"""Verify _commit_plan was called."""
|
||||
assert context.budget_lifecycle._commit_plan.called, (
|
||||
"Expected _commit_plan to be called"
|
||||
)
|
||||
|
||||
|
||||
@then("the plan error_details should contain budget_halt true")
|
||||
def step_check_error_details_budget_halt(context: Context) -> None:
|
||||
"""Verify plan error_details has budget_halt."""
|
||||
plan = context.budget_plan
|
||||
assert isinstance(plan.error_details, dict), (
|
||||
f"Expected dict error_details, got {type(plan.error_details)}"
|
||||
)
|
||||
assert plan.error_details.get("budget_halt") == "true", (
|
||||
f"Expected budget_halt='true', got {plan.error_details.get('budget_halt')!r}"
|
||||
)
|
||||
|
||||
|
||||
@then('the plan error_details should contain budget_type "{expected}"')
|
||||
def step_check_error_details_budget_type(context: Context, expected: str) -> None:
|
||||
"""Verify plan error_details has correct budget_type."""
|
||||
plan = context.budget_plan
|
||||
assert isinstance(plan.error_details, dict)
|
||||
assert plan.error_details.get("budget_type") == expected, (
|
||||
f"Expected budget_type={expected!r}, got {plan.error_details.get('budget_type')!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("no exception should be raised from _save_plan_state_on_budget_halt")
|
||||
def step_no_exception_from_save(context: Context) -> None:
|
||||
"""Verify no exception was raised from _save_plan_state_on_budget_halt."""
|
||||
assert context.budget_raised is None, (
|
||||
f"Expected no exception, got {type(context.budget_raised).__name__}: "
|
||||
f"{context.budget_raised}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AutomationProfile budget fields steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I create an AutomationProfile with budget_per_plan {budget:f}")
|
||||
def step_create_profile_with_plan_budget(context: Context, budget: float) -> None:
|
||||
"""Create an AutomationProfile with budget_per_plan."""
|
||||
context.budget_profile = AutomationProfile(
|
||||
name="test-budget-profile",
|
||||
budget_per_plan=budget,
|
||||
)
|
||||
|
||||
|
||||
@when("I create an AutomationProfile with budget_per_session {budget:f}")
|
||||
def step_create_profile_with_session_budget(context: Context, budget: float) -> None:
|
||||
"""Create an AutomationProfile with budget_per_session."""
|
||||
context.budget_profile = AutomationProfile(
|
||||
name="test-budget-profile",
|
||||
budget_per_session=budget,
|
||||
)
|
||||
|
||||
|
||||
@when("I create an AutomationProfile with default budget fields")
|
||||
def step_create_profile_with_default_budget(context: Context) -> None:
|
||||
"""Create an AutomationProfile with default budget fields."""
|
||||
context.budget_profile = AutomationProfile(name="test-default-profile")
|
||||
|
||||
|
||||
@then("the AutomationProfile budget_per_plan should be {expected}")
|
||||
def step_check_profile_plan_budget(context: Context, expected: str) -> None:
|
||||
"""Verify AutomationProfile budget_per_plan."""
|
||||
if expected == "None":
|
||||
assert context.budget_profile.budget_per_plan is None, (
|
||||
f"Expected None, got {context.budget_profile.budget_per_plan}"
|
||||
)
|
||||
else:
|
||||
assert context.budget_profile.budget_per_plan == float(expected), (
|
||||
f"Expected {expected}, got {context.budget_profile.budget_per_plan}"
|
||||
)
|
||||
|
||||
|
||||
@then("the AutomationProfile budget_per_session should be {expected}")
|
||||
def step_check_profile_session_budget(context: Context, expected: str) -> None:
|
||||
"""Verify AutomationProfile budget_per_session."""
|
||||
if expected == "None":
|
||||
assert context.budget_profile.budget_per_session is None, (
|
||||
f"Expected None, got {context.budget_profile.budget_per_session}"
|
||||
)
|
||||
else:
|
||||
assert context.budget_profile.budget_per_session == float(expected), (
|
||||
f"Expected {expected}, got {context.budget_profile.budget_per_session}"
|
||||
)
|
||||
|
||||
|
||||
@when("I try to create an AutomationProfile with budget_per_plan {budget:f}")
|
||||
def step_try_create_profile_negative_plan_budget(
|
||||
context: Context, budget: float
|
||||
) -> None:
|
||||
"""Try to create an AutomationProfile with invalid budget_per_plan."""
|
||||
try:
|
||||
context.budget_profile = AutomationProfile(
|
||||
name="test-profile",
|
||||
budget_per_plan=budget,
|
||||
)
|
||||
context.budget_raised = None
|
||||
except Exception as exc:
|
||||
context.budget_raised = exc
|
||||
|
||||
|
||||
@when("I try to create an AutomationProfile with budget_per_session {budget:f}")
|
||||
def step_try_create_profile_negative_session_budget(
|
||||
context: Context, budget: float
|
||||
) -> None:
|
||||
"""Try to create an AutomationProfile with invalid budget_per_session."""
|
||||
try:
|
||||
context.budget_profile = AutomationProfile(
|
||||
name="test-profile",
|
||||
budget_per_session=budget,
|
||||
)
|
||||
context.budget_raised = None
|
||||
except Exception as exc:
|
||||
context.budget_raised = exc
|
||||
|
||||
|
||||
@then("a budget enforcement validation error should be raised")
|
||||
def step_check_budget_validation_error(context: Context) -> None:
|
||||
"""Verify a validation error was raised."""
|
||||
assert context.budget_raised is not None, (
|
||||
"Expected a validation error but none was raised"
|
||||
)
|
||||
assert "validation" in type(context.budget_raised).__name__.lower() or "value" in str(
|
||||
context.budget_raised
|
||||
).lower(), (
|
||||
f"Expected validation error, got {type(context.budget_raised).__name__}: "
|
||||
f"{context.budget_raised}"
|
||||
)
|
||||
@@ -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."""
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
"""Steps for REPL and Actor Run CLI Showcase Documentation tests."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
|
||||
@given("the showcase directory exists")
|
||||
def step_showcase_directory_exists(context: Any) -> None:
|
||||
"""Verify the showcase directory exists."""
|
||||
showcase_dir = Path("docs/showcase")
|
||||
assert showcase_dir.exists(), f"Showcase directory not found at {showcase_dir}"
|
||||
context.showcase_dir = showcase_dir
|
||||
|
||||
|
||||
@given("the examples.json file exists")
|
||||
def step_examples_json_exists(context: Any) -> None:
|
||||
"""Verify examples.json exists."""
|
||||
examples_file = Path("docs/showcase/examples.json")
|
||||
assert examples_file.exists(), f"examples.json not found at {examples_file}"
|
||||
context.examples_file = examples_file
|
||||
|
||||
# Load the JSON for later use
|
||||
with open(examples_file) as f:
|
||||
context.examples_data = json.load(f)
|
||||
|
||||
|
||||
@given("the showcase markdown file exists")
|
||||
def step_showcase_markdown_exists(context: Any) -> None:
|
||||
"""Verify the showcase markdown file exists."""
|
||||
markdown_file = Path("docs/showcase/cli-tools/repl-and-actor-run.md")
|
||||
assert markdown_file.exists(), f"Showcase markdown not found at {markdown_file}"
|
||||
context.markdown_file = markdown_file
|
||||
|
||||
# Load the content for later use
|
||||
with open(markdown_file) as f:
|
||||
context.markdown_content = f.read()
|
||||
|
||||
|
||||
@when("I check for the REPL and actor run showcase file")
|
||||
def step_check_showcase_file(context: Any) -> None:
|
||||
"""Check for the showcase file."""
|
||||
context.file_path = Path("docs/showcase/cli-tools/repl-and-actor-run.md")
|
||||
|
||||
|
||||
@then('the file should exist at "{path}"')
|
||||
def step_file_exists_at_path(context: Any, path: str) -> None:
|
||||
"""Verify file exists at the given path."""
|
||||
file_path = Path(path)
|
||||
assert file_path.exists(), f"File not found at {path}"
|
||||
|
||||
|
||||
@when("I check for the REPL and actor run showcase entry")
|
||||
def step_check_showcase_entry(context: Any) -> None:
|
||||
"""Check for the showcase entry in examples.json."""
|
||||
examples = context.examples_data.get("examples", [])
|
||||
|
||||
# Find the REPL and actor run entry
|
||||
context.repl_entry = None
|
||||
for example in examples:
|
||||
if "repl-and-actor-run" in example.get("path", ""):
|
||||
context.repl_entry = example
|
||||
break
|
||||
|
||||
assert context.repl_entry is not None, (
|
||||
"REPL and actor run entry not found in examples.json"
|
||||
)
|
||||
|
||||
|
||||
@then('the entry should have title "{title}"')
|
||||
def step_entry_has_title(context: Any, title: str) -> None:
|
||||
"""Verify entry has the expected title."""
|
||||
assert context.repl_entry["title"] == title, (
|
||||
f"Expected title '{title}', got '{context.repl_entry['title']}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the entry should have category "{category}"')
|
||||
def step_entry_has_category(context: Any, category: str) -> None:
|
||||
"""Verify entry has the expected category."""
|
||||
assert context.repl_entry["category"] == category, (
|
||||
f"Expected category '{category}', got '{context.repl_entry['category']}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the entry should have path "{path}"')
|
||||
def step_entry_has_path(context: Any, path: str) -> None:
|
||||
"""Verify entry has the expected path."""
|
||||
assert context.repl_entry["path"] == path, (
|
||||
f"Expected path '{path}', got '{context.repl_entry['path']}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the entry should have complexity "{complexity}"')
|
||||
def step_entry_has_complexity(context: Any, complexity: str) -> None:
|
||||
"""Verify entry has the expected complexity."""
|
||||
assert context.repl_entry["complexity"] == complexity, (
|
||||
f"Expected complexity '{complexity}', got '{context.repl_entry['complexity']}'"
|
||||
)
|
||||
|
||||
|
||||
@when("I check the documented commands")
|
||||
def step_check_documented_commands(context: Any) -> None:
|
||||
"""Check the documented commands in the markdown."""
|
||||
pass # Content is already loaded in context.markdown_content
|
||||
|
||||
|
||||
@then('the file should contain "{text}"')
|
||||
def step_file_contains_text(context: Any, text: str) -> None:
|
||||
"""Verify the file contains the given text."""
|
||||
assert text in context.markdown_content, f"Text '{text}' not found in markdown file"
|
||||
|
||||
|
||||
@when("I check the content structure")
|
||||
def step_check_content_structure(context: Any) -> None:
|
||||
"""Check the content structure."""
|
||||
pass # Content is already loaded
|
||||
|
||||
|
||||
@when("I check for verified output")
|
||||
def step_check_verified_output(context: Any) -> None:
|
||||
"""Check for verified output in the markdown."""
|
||||
pass # Content is already loaded
|
||||
|
||||
|
||||
@when("I parse the JSON")
|
||||
def step_parse_json(context: Any) -> None:
|
||||
"""Parse the JSON file."""
|
||||
# Already parsed in the given step
|
||||
pass
|
||||
|
||||
|
||||
@then("the JSON should be valid")
|
||||
def step_json_is_valid(context: Any) -> None:
|
||||
"""Verify the JSON is valid."""
|
||||
assert isinstance(context.examples_data, dict), "JSON is not a valid dictionary"
|
||||
|
||||
|
||||
@then('the JSON should have "{key}" key')
|
||||
def step_json_has_key(context: Any, key: str) -> None:
|
||||
"""Verify the JSON has the given key."""
|
||||
assert key in context.examples_data, f"Key '{key}' not found in JSON"
|
||||
|
||||
|
||||
@when("I check the REPL and actor run entry")
|
||||
def step_check_repl_entry(context: Any) -> None:
|
||||
"""Check the REPL and actor run entry."""
|
||||
# Already done in previous step
|
||||
pass
|
||||
|
||||
|
||||
@then('the entry should have "{field}" field')
|
||||
def step_entry_has_field(context: Any, field: str) -> None:
|
||||
"""Verify entry has the given field."""
|
||||
assert field in context.repl_entry, f"Field '{field}' not found in entry"
|
||||
|
||||
|
||||
@then("the commands list should have at least {count:d} items")
|
||||
def step_commands_list_has_items(context: Any, count: int) -> None:
|
||||
"""Verify the commands list has at least the given number of items."""
|
||||
commands = context.repl_entry.get("commands", [])
|
||||
assert len(commands) >= count, (
|
||||
f"Expected at least {count} commands, got {len(commands)}"
|
||||
)
|
||||
|
||||
|
||||
@then('the commands should include "{command}"')
|
||||
def step_commands_include(context: Any, command: str) -> None:
|
||||
"""Verify the commands list includes the given command."""
|
||||
commands = context.repl_entry.get("commands", [])
|
||||
assert command in commands, f"Command '{command}' not found in commands list"
|
||||
|
||||
|
||||
@when("I check the markdown structure")
|
||||
def step_check_markdown_structure(context: Any) -> None:
|
||||
"""Check the markdown structure."""
|
||||
pass # Content is already loaded
|
||||
|
||||
|
||||
@then("the file should start with a heading")
|
||||
def step_file_starts_with_heading(context: Any) -> None:
|
||||
"""Verify the file starts with a heading."""
|
||||
assert context.markdown_content.startswith("#"), (
|
||||
"Markdown file should start with a heading"
|
||||
)
|
||||
|
||||
|
||||
@then("the file should have multiple sections")
|
||||
def step_file_has_multiple_sections(context: Any) -> None:
|
||||
"""Verify the file has multiple sections."""
|
||||
heading_count = context.markdown_content.count("\n#")
|
||||
assert heading_count >= 3, f"Expected at least 3 sections, found {heading_count}"
|
||||
|
||||
|
||||
@then("the file should have code blocks")
|
||||
def step_file_has_code_blocks(context: Any) -> None:
|
||||
"""Verify the file has code blocks."""
|
||||
assert "```" in context.markdown_content, "Markdown file should have code blocks"
|
||||
|
||||
|
||||
@then("the file should have proper link formatting")
|
||||
def step_file_has_proper_links(context: Any) -> None:
|
||||
"""Verify the file has proper link formatting."""
|
||||
# Check for markdown link format [text](url)
|
||||
assert "[" in context.markdown_content and "](" in context.markdown_content, (
|
||||
"Markdown file should have proper link formatting"
|
||||
)
|
||||
@@ -1,287 +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}"
|
||||
|
||||
|
||||
@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
|
||||
# Note: We can't instantiate _TextualCleverAgentsTuiApp directly without Textual
|
||||
# So we'll test the session management logic separately
|
||||
|
||||
|
||||
@when("the TUI app is created")
|
||||
def step_create_tui_app(context: object) -> None:
|
||||
"""Create a TUI app instance."""
|
||||
# Create a mock app with session management
|
||||
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(context: object, count: int) -> None:
|
||||
"""Check the number of sessions."""
|
||||
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."""
|
||||
active = context.app._sessions[context.app._active_session_index] # type: ignore
|
||||
assert active.session_id == 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."""
|
||||
active = context.app._sessions[context.app._active_session_index] # type: ignore
|
||||
assert active.name == 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
|
||||
|
||||
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."""
|
||||
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(context: object, count: int) -> None:
|
||||
"""Set up the TUI app with a specific number of sessions."""
|
||||
context.app = type("MockApp", (), {})() # type: ignore
|
||||
context.app._sessions = [] # type: ignore
|
||||
for i in range(count):
|
||||
if i == 0:
|
||||
session_id = "default"
|
||||
name = "Default"
|
||||
else:
|
||||
import uuid
|
||||
|
||||
session_id = str(uuid.uuid4())[:8]
|
||||
name = 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("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."""
|
||||
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."""
|
||||
assert 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."""
|
||||
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 close the session with session_id {session_id}")
|
||||
def step_close_session(context: object, session_id: str) -> None:
|
||||
"""Close a session."""
|
||||
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)."""
|
||||
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
|
||||
|
||||
|
||||
@when("I rename the session to {new_name}")
|
||||
def step_rename_session(context: object, new_name: str) -> None:
|
||||
"""Rename the active session."""
|
||||
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."""
|
||||
active = context.app._sessions[context.app._active_session_index] # type: ignore
|
||||
assert active.name == name
|
||||
|
||||
|
||||
@given("the first session is active")
|
||||
def step_first_session_active(context: object) -> None:
|
||||
"""Make the first session active."""
|
||||
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."""
|
||||
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."""
|
||||
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."""
|
||||
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."""
|
||||
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."""
|
||||
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."""
|
||||
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."""
|
||||
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."""
|
||||
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."""
|
||||
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."""
|
||||
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."""
|
||||
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
|
||||
|
||||
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)
|
||||
@@ -1,37 +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
|
||||
@@ -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,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
|
||||
@@ -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"
|
||||
@@ -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
-2
@@ -10,9 +10,9 @@ site_dir: build/site
|
||||
|
||||
nav:
|
||||
- Specification: specification.md
|
||||
- Guides:
|
||||
- Installation and Setup: guides/installation-setup.md
|
||||
- Architecture: architecture.md
|
||||
- Guides:
|
||||
- Getting Started: guides/getting-started.md
|
||||
- API Reference:
|
||||
- Overview: api/index.md
|
||||
- Core Utilities: api/core.md
|
||||
|
||||
+131
-86
@@ -1,95 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create pipeline improvement issues in Forgejo."""
|
||||
"""ADR compliance checker for CleverAgents.
|
||||
|
||||
import json
|
||||
Verifies that code follows the architectural decisions recorded in ADRs.
|
||||
|
||||
ADR-002: Asyncio Concurrency Model
|
||||
- Async functions should use 'async def', not threading
|
||||
- No direct thread usage in application layer
|
||||
|
||||
ADR-003: Dependency Injection Framework
|
||||
- Services should receive dependencies via __init__
|
||||
- No direct instantiation of infrastructure in application layer
|
||||
|
||||
ADR-007: Repository Pattern for Persistence
|
||||
- Database access only through repository classes
|
||||
- No direct SQLAlchemy session usage in services
|
||||
|
||||
Usage:
|
||||
python scripts/check-adr-compliance.py
|
||||
"""
|
||||
|
||||
import ast
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
BASE_URL = "https://git.cleverthis.com/api/v1"
|
||||
REPO = "cleveragents/cleveragents-core"
|
||||
PAT = "92224acff675c50c5958d1eaca9a688abd405e06"
|
||||
H = {"Authorization": f"token {PAT}", "Content-Type": "application/json"}
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def post(url, payload):
|
||||
data = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(url, data=data, headers=H, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return json.loads(r.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"HTTP {e.code}: {e.read().decode()}", file=sys.stderr)
|
||||
raise
|
||||
def check_adr002_async_model(src_dir: Path) -> list[str]:
|
||||
"""Check ADR-002: Asyncio Concurrency Model.
|
||||
|
||||
Verifies no direct threading usage in application code.
|
||||
"""
|
||||
violations: list[str] = []
|
||||
app_dir = src_dir / "application"
|
||||
if not app_dir.exists():
|
||||
return violations
|
||||
|
||||
for py_file in app_dir.rglob("*.py"):
|
||||
content = py_file.read_text()
|
||||
if "import threading" in content or "from threading import" in content:
|
||||
violations.append(
|
||||
f"ADR-002 violation: {py_file.relative_to(src_dir)} "
|
||||
"imports threading directly. Use asyncio instead."
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
ISSUES = [
|
||||
{
|
||||
"title": "`benchmark-regression` job in `master.yml` has unreachable trigger condition",
|
||||
"body": "## Issue Description\n\nThe `benchmark-regression` job in `.forgejo/workflows/master.yml` contains an `if` condition that can **never be true**, making the job permanently dead code. Benchmark regression testing on pull requests is silently broken.\n\n## Root Cause\n\n`master.yml` is triggered only on `push` events to `master` and `develop` branches. But the `benchmark-regression` job has:\n\n```yaml\nbenchmark-regression:\n if: forgejo.event_name == 'pull_request'\n```\n\nSince `master.yml` never triggers on `pull_request` events, `forgejo.event_name` will always be `push` — the condition is permanently `false` and the job **never runs**.\n\n## Impact\n\n- Benchmark regression testing on PRs is completely broken and silently skipped\n- Performance regressions can merge undetected\n- The `benchmark-publish` job (push-triggered) works correctly, but the PR regression check does not\n\n## Expected Behavior\n\nFix options:\n1. **Option A (Recommended)**: Move `benchmark-regression` to `ci.yml` (which triggers on `pull_request`)\n2. **Option B**: Add `pull_request` to `master.yml`'s `on:` trigger\n\n## Subtasks\n\n- [ ] Move or restructure `benchmark-regression` so it triggers on `pull_request` events\n- [ ] Verify `benchmark-publish` continues to run on push to `master`/`develop`\n- [ ] Confirm CI passes with corrected workflow\n\n## Definition of Done\n\nThis issue is complete when:\n- All subtasks above are completed and checked off.\n- A Git commit is created where the **first line** of the commit message matches the Commit Message in Metadata exactly.\n- The commit is pushed to the remote on the branch matching the **Branch** in Metadata exactly.\n- The commit is submitted as a **pull request** to `master`, reviewed, and **merged**.\n\n## Metadata\n\n- **Commit Message**: `fix(ci): restore benchmark-regression trigger to pull_request events in master.yml`\n- **Branch Name**: `fix/ci-benchmark-regression-trigger`\n\n## Duplicate Check\n\nSearched open and closed issues for: \"benchmark regression master.yml\", \"benchmark workflow trigger\", \"pull_request event_name\". No existing issues found.\n\n---\n**Automated by CleverAgents Bot**\nSupervisor: Implementation Pool | Agent: implementation-worker",
|
||||
"milestone": 105,
|
||||
"assignee": "HAL9000",
|
||||
"labels": [849, 859, 883, 874, 846],
|
||||
},
|
||||
{
|
||||
"title": "`release.yml` publishes artifacts without requiring quality gates to pass first",
|
||||
"body": "## Issue Description\n\nThe `.forgejo/workflows/release.yml` workflow builds and publishes release artifacts (Python wheel, Docker images, Forgejo release) without requiring **any quality gates** to pass first. A release could be published from broken, untested, or insecure code.\n\n## Current Behavior\n\n`release.yml` triggers on `push` of `v*` tags and runs `build-wheel`, `build-docker`, and `create-release` — none of which require lint, typecheck, security scan, unit tests, integration tests, or coverage to pass.\n\n## Risk\n\n- A release tag pushed directly (bypassing CI) publishes untested artifacts\n- A release could contain failing tests, type errors, or security vulnerabilities\n- The 97% coverage requirement is not enforced at release time\n\n## Expected Behavior\n\nAdd a `quality-gate` job that runs `nox -s lint typecheck security_scan unit_tests coverage_report` as a prerequisite for `build-wheel`.\n\n## Subtasks\n\n- [ ] Add `quality-gate` job to `release.yml` that runs lint, typecheck, security_scan, unit_tests, coverage_report\n- [ ] Update `build-wheel` to `needs: [quality-gate]`\n- [ ] Verify release workflow still completes successfully when all gates pass\n- [ ] Document the quality gate requirement in release workflow comments\n\n## Definition of Done\n\nThis issue is complete when:\n- All subtasks above are completed and checked off.\n- A Git commit is created where the **first line** of the commit message matches the Commit Message in Metadata exactly.\n- The commit is pushed to the remote on the branch matching the **Branch** in Metadata exactly.\n- The commit is submitted as a **pull request** to `master`, reviewed, and **merged**.\n\n## Metadata\n\n- **Commit Message**: `fix(ci): add quality gate prerequisite to release.yml before artifact publication`\n- **Branch Name**: `fix/ci-release-quality-gate`\n\n## Duplicate Check\n\nSearched open and closed issues for: \"release quality gate\", \"release workflow test\", \"release.yml CI\". No existing issues found.\n\n---\n**Automated by CleverAgents Bot**\nSupervisor: Implementation Pool | Agent: implementation-worker",
|
||||
"milestone": 105,
|
||||
"assignee": "HAL9000",
|
||||
"labels": [849, 859, 883, 875, 846],
|
||||
},
|
||||
{
|
||||
"title": "Standardize `actions/upload-artifact` version across all CI workflows (v3 vs v4 inconsistency)",
|
||||
"body": "## Issue Description\n\nThe CI workflows use inconsistent versions of `actions/upload-artifact`:\n\n- `ci.yml` uses `actions/upload-artifact@v3` (all jobs)\n- `nightly-quality.yml` uses `actions/upload-artifact@v4` (quality reports upload)\n\nThis version mismatch creates maintenance risk and potential behavioral differences in artifact handling between workflows.\n\n## Impact\n\n- `actions/upload-artifact@v3` and `v4` have different behaviors (v4 changed artifact naming, retention, and download compatibility)\n- Artifacts uploaded with v3 cannot be downloaded with `actions/download-artifact@v4` and vice versa\n- The `release.yml` uses `actions/download-artifact@v3` to download the wheel artifact\n- Inconsistency makes it harder to reason about artifact compatibility across workflows\n\n## Expected Behavior\n\nStandardize on `@v4` (the current stable version) across all workflows.\n\n## Subtasks\n\n- [ ] Audit all workflow files for `upload-artifact` and `download-artifact` action versions\n- [ ] Standardize all to `@v4` (or document a deliberate reason to stay on `@v3`)\n- [ ] Verify artifact upload/download compatibility after the change\n- [ ] Update `release.yml`'s `download-artifact` step to match\n\n## Definition of Done\n\nThis issue is complete when:\n- All subtasks above are completed and checked off.\n- A Git commit is created where the **first line** of the commit message matches the Commit Message in Metadata exactly.\n- The commit is pushed to the remote on the branch matching the **Branch** in Metadata exactly.\n- The commit is submitted as a **pull request** to `master`, reviewed, and **merged**.\n\n## Metadata\n\n- **Commit Message**: `fix(ci): standardize actions/upload-artifact to v4 across all workflow files`\n- **Branch Name**: `fix/ci-artifact-action-version-consistency`\n\n## Duplicate Check\n\nSearched open and closed issues for: \"artifact upload version\", \"upload-artifact v3 v4\", \"actions version inconsistency\". No existing issues found.\n\n---\n**Automated by CleverAgents Bot**\nSupervisor: Implementation Pool | Agent: implementation-worker",
|
||||
"milestone": 105,
|
||||
"assignee": "HAL9000",
|
||||
"labels": [857, 860, 884, 874, 846],
|
||||
},
|
||||
{
|
||||
"title": "CI `coverage` job should depend on `unit_tests` to prevent misleading parallel results",
|
||||
"body": "## Issue Description\n\nThe `coverage` job in `.forgejo/workflows/ci.yml` only depends on `[lint, typecheck, security, quality]` but does **not** depend on `unit_tests`. This means the coverage job runs in parallel with the test jobs, potentially wasting CI resources and producing misleading intermediate results.\n\n## Current State\n\n```yaml\ncoverage:\n needs: [lint, typecheck, security, quality]\n # Missing: unit_tests\n```\n\nThe `coverage` job runs `nox -s coverage_report`, which internally re-runs the full Behave test suite under slipcover. This means:\n- Coverage runs its own copy of the tests regardless of whether `unit_tests` passed\n- If `unit_tests` fails, coverage may still pass (running the same tests again)\n- The two jobs run the same tests twice in parallel, wasting CI resources\n\n## Expected Behavior\n\n```yaml\ncoverage:\n needs: [lint, typecheck, security, quality, unit_tests]\n```\n\n## Subtasks\n\n- [ ] Update `coverage` job's `needs` in `ci.yml` to include `unit_tests`\n- [ ] Verify CI pipeline still runs correctly with the updated dependency\n- [ ] Confirm no circular dependencies are introduced\n\n## Definition of Done\n\nThis issue is complete when:\n- All subtasks above are completed and checked off.\n- A Git commit is created where the **first line** of the commit message matches the Commit Message in Metadata exactly.\n- The commit is pushed to the remote on the branch matching the **Branch** in Metadata exactly.\n- The commit is submitted as a **pull request** to `master`, reviewed, and **merged**.\n\n## Metadata\n\n- **Commit Message**: `fix(ci): add unit_tests to coverage job needs to prevent misleading parallel results`\n- **Branch Name**: `fix/ci-coverage-job-ordering`\n\n## Duplicate Check\n\nSearched open and closed issues for: \"coverage needs unit_tests\", \"coverage job ordering\", \"coverage parallel tests\". No existing issues found.\n\n---\n**Automated by CleverAgents Bot**\nSupervisor: Implementation Pool | Agent: implementation-worker",
|
||||
"milestone": 105,
|
||||
"assignee": "HAL9000",
|
||||
"labels": [857, 860, 884, 873, 846],
|
||||
},
|
||||
{
|
||||
"title": "Add `adr_compliance` nox session to CI pipeline to enforce Architecture Decision Record compliance",
|
||||
"body": "## Issue Description\n\nThe `noxfile.py` defines an `adr_compliance` session that checks code compliance with Architecture Decision Records (ADRs), but this session is **not included in the CI pipeline** (`ci.yml`). ADR compliance violations can therefore merge undetected.\n\nRunning `nox -s adr_compliance` locally reveals **56 existing violations** (threading imports in async services, direct SQLAlchemy usage in service layer), confirming the check is meaningful and needed.\n\n## Current State\n\n`noxfile.py` defines `adr_compliance` which runs `scripts/check-adr-compliance.py`, but `ci.yml` does NOT include this session in any job. The `nox.options.sessions` default list also does not include `adr_compliance`.\n\n## Impact\n\n- ADR compliance violations can be merged without detection\n- The `scripts/check-adr-compliance.py` script exists but is never run automatically\n- 56 existing violations are currently undetected in CI\n- Developers may unknowingly violate architectural decisions documented in ADRs\n\n## Expected Behavior\n\nAdd `nox -s adr_compliance` to the `quality` job in `ci.yml` alongside the existing `complexity` check.\n\n**Note**: The existing 56 violations must be resolved (or the ADR checker updated to reflect current architectural decisions) before this check can be enforced as a hard gate.\n\n## Subtasks\n\n- [ ] Resolve or document the 56 existing ADR violations found by `nox -s adr_compliance`\n- [ ] Add `nox -s adr_compliance` to the `quality` job in `ci.yml`\n- [ ] Add `adr_compliance` to `nox.options.sessions` default list in `noxfile.py`\n- [ ] Confirm CI passes with the added check\n\n## Definition of Done\n\nThis issue is complete when:\n- All subtasks above are completed and checked off.\n- A Git commit is created where the **first line** of the commit message matches the Commit Message in Metadata exactly.\n- The commit is pushed to the remote on the branch matching the **Branch** in Metadata exactly.\n- The commit is submitted as a **pull request** to `master`, reviewed, and **merged**.\n\n## Metadata\n\n- **Commit Message**: `feat(ci): add adr_compliance nox session to CI quality job`\n- **Branch Name**: `feat/ci-adr-compliance-check`\n\n## Duplicate Check\n\nSearched open and closed issues for: \"adr compliance CI\", \"adr_compliance nox\", \"architecture decision record CI\". No existing issues found.\n\n---\n**Automated by CleverAgents Bot**\nSupervisor: Implementation Pool | Agent: implementation-worker",
|
||||
"milestone": 105,
|
||||
"assignee": "HAL9000",
|
||||
"labels": [854, 860, 884, 874, 846],
|
||||
},
|
||||
]
|
||||
def check_adr003_dependency_injection(src_dir: Path) -> list[str]:
|
||||
"""Check ADR-003: Dependency Injection Framework.
|
||||
|
||||
created = []
|
||||
for issue in ISSUES:
|
||||
print(f"Creating: {issue['title'][:60]}...")
|
||||
try:
|
||||
r = post(
|
||||
f"{BASE_URL}/repos/{REPO}/issues",
|
||||
{
|
||||
"title": issue["title"],
|
||||
"body": issue["body"],
|
||||
"milestone": issue["milestone"],
|
||||
"assignee": issue["assignee"],
|
||||
},
|
||||
)
|
||||
num, url = r["number"], r["html_url"]
|
||||
print(f" Created #{num}: {url}")
|
||||
if issue.get("labels"):
|
||||
try:
|
||||
post(
|
||||
f"{BASE_URL}/repos/{REPO}/issues/{num}/labels",
|
||||
{"labels": issue["labels"]},
|
||||
)
|
||||
print(" Labels applied")
|
||||
except Exception as e:
|
||||
print(f" Labels FAILED: {e}")
|
||||
created.append((num, url, issue["title"]))
|
||||
except Exception as e:
|
||||
print(f" FAILED: {e}")
|
||||
Verifies services use constructor injection.
|
||||
"""
|
||||
violations: list[str] = []
|
||||
services_dir = src_dir / "application" / "services"
|
||||
if not services_dir.exists():
|
||||
return violations
|
||||
|
||||
print("\n=== SUMMARY ===")
|
||||
for num, url, title in created:
|
||||
print(f"#{num}: {title[:60]}")
|
||||
print(f" {url}")
|
||||
for py_file in services_dir.rglob("*.py"):
|
||||
if py_file.name.startswith("__"):
|
||||
continue
|
||||
try:
|
||||
tree = ast.parse(py_file.read_text())
|
||||
except SyntaxError:
|
||||
continue
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ClassDef):
|
||||
# Check if class has __init__ with parameters (DI)
|
||||
has_init = False
|
||||
for item in node.body:
|
||||
if isinstance(item, ast.FunctionDef) and item.name == "__init__":
|
||||
has_init = True
|
||||
# Check for at least one parameter beyond self
|
||||
if len(item.args.args) < 2:
|
||||
violations.append(
|
||||
f"ADR-003 note: {py_file.relative_to(src_dir)}:"
|
||||
f"{node.lineno} class {node.name} has __init__ "
|
||||
"with no injected dependencies"
|
||||
)
|
||||
if not has_init and node.body:
|
||||
# Services without __init__ may be okay if they're utility classes
|
||||
pass
|
||||
return violations
|
||||
|
||||
|
||||
def check_adr007_repository_pattern(src_dir: Path) -> list[str]:
|
||||
"""Check ADR-007: Repository Pattern for Persistence.
|
||||
|
||||
Verifies no direct SQLAlchemy session usage in service layer.
|
||||
"""
|
||||
violations: list[str] = []
|
||||
services_dir = src_dir / "application" / "services"
|
||||
if not services_dir.exists():
|
||||
return violations
|
||||
|
||||
for py_file in services_dir.rglob("*.py"):
|
||||
content = py_file.read_text()
|
||||
# Check for direct session imports
|
||||
if "from sqlalchemy" in content:
|
||||
violations.append(
|
||||
f"ADR-007 violation: {py_file.relative_to(src_dir)} "
|
||||
"imports SQLAlchemy directly. Use repository pattern instead."
|
||||
)
|
||||
if "session.query" in content or "session.execute" in content:
|
||||
violations.append(
|
||||
f"ADR-007 violation: {py_file.relative_to(src_dir)} "
|
||||
"uses session directly. Access data through repositories."
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run all ADR compliance checks."""
|
||||
src_dir = Path("src/cleveragents")
|
||||
if not src_dir.exists():
|
||||
print("Error: src/cleveragents directory not found")
|
||||
return 1
|
||||
|
||||
all_violations: list[str] = []
|
||||
|
||||
print("Checking ADR-002: Asyncio Concurrency Model...")
|
||||
all_violations.extend(check_adr002_async_model(src_dir))
|
||||
|
||||
print("Checking ADR-003: Dependency Injection Framework...")
|
||||
all_violations.extend(check_adr003_dependency_injection(src_dir))
|
||||
|
||||
print("Checking ADR-007: Repository Pattern for Persistence...")
|
||||
all_violations.extend(check_adr007_repository_pattern(src_dir))
|
||||
|
||||
if all_violations:
|
||||
print(f"\nFound {len(all_violations)} ADR compliance issue(s):")
|
||||
for violation in all_violations:
|
||||
print(f" - {violation}")
|
||||
return 1
|
||||
|
||||
print("\nAll ADR compliance checks passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
+9
-105
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
-1
Submodule work/repo deleted from 435e409df9
Reference in New Issue
Block a user