feat: Add hooks, workflow, MCP support and core modules

Phase 2: Hooks System
- Event-driven execution with HookEvent/HookAction types
- HookManager for registration and execution

Phase 3: Multi-Agent Workflow
- Workflow orchestration with dependency graphs
- WorkflowStep with conditions and retry support

Phase 4: MCP (Model Context Protocol) Support
- McpServer for external tool servers
- McpManager for multiple server management

Also includes core modules: cache, error_policy, feature_flags,
git, logger, plugin, retry, rules

662 tests passing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Shunsuke Hayashi 2025-11-22 23:37:14 +09:00
parent 3f6fbeb498
commit 48dfa915c7
27 changed files with 4793 additions and 34 deletions

View file

@ -1,57 +1,316 @@
# .miyabi Directory
Miyabi CLI設定ディレクトリ
Miyabi CLI設定・拡張ディレクトリ
## 構造
## ディレクトリ構造
```
.miyabi/
├── config.toml # メイン設定ファイル
├── README.md # このファイル
├── agents/
│ └── specs/ # Agent仕様定義
├── commands/ # カスタムコマンド
├── commands/ # カスタムスラッシュコマンド
├── prompts/ # 再利用可能プロンプト
├── templates/ # テンプレート
├── sessions/ # 保存されたセッション
└── config.toml # 設定ファイル
├── templates/ # コード・ドキュメントテンプレート
├── sessions/ # 保存されたセッションデータ
└── tasks/ # タスク管理データ
```
## 設定ファイル
## 設定ファイル (config.toml)
`config.toml` で設定をカスタマイズ:
### API設定
```toml
[api]
model = "claude-sonnet-4-20250514"
# API Key (環境変数 ANTHROPIC_API_KEY でも設定可能)
api_key = "sk-ant-..."
# 使用モデル
model = "claude-sonnet-4-5-20250929"
# 最大トークン数
max_tokens = 8192
# タイムアウト (秒)
timeout_secs = 120
# リトライ回数
max_retries = 3
# 拡張思考モード
thinking = false
# システムプロンプト
system_prompt = "You are a helpful assistant"
```
### UI設定
```toml
[ui]
# テーマ: tokyo-night, dracula, monokai, etc.
theme = "tokyo-night"
# Vimキーバインド
vim_mode = false
# サイドバー表示
show_sidebar = false
# ステータスバー表示
show_status_bar = true
# パンくずリスト表示
show_breadcrumb = true
# コードの行番号表示
show_line_numbers = true
```
### セッション設定
```toml
[session]
# 自動保存
auto_save = true
# 保存間隔 (秒)
auto_save_interval = 30
# 最大セッション数
max_sessions = 100
```
### ツール設定
```toml
[tools]
# Bashツール有効化
enable_bash = true
# ファイル操作ツール有効化
enable_file_tools = true
# 検索ツール有効化
enable_search_tools = true
# 低リスク操作の自動承認
auto_approve_low_risk = false
# Bashタイムアウト (秒)
bash_timeout = 120
```
## カスタムコマンド
`commands/` 配下に `*.md` ファイルを作成してカスタムコマンドを定義。
`commands/` 配下に `.md` ファイルを作成してスラッシュコマンドを定義。
### 例: /review コマンド
`commands/review.md`:
```markdown
# コードレビュー
以下のコードをレビューしてください:
1. バグの可能性
2. パフォーマンス問題
3. セキュリティ脆弱性
4. コーディング規約違反
改善提案も含めてください。
```
使用: TUIで `/review` と入力
## プロンプトテンプレート
`prompts/` 配下に再利用可能なプロンプトを保存。
### 例: Rustコードレビュー
`prompts/rust-review.md`:
```markdown
# Rustコードレビューチェックリスト
- [ ] unwrap/expect の使用箇所
- [ ] エラーハンドリング
- [ ] 所有権とライフタイム
- [ ] unsafe コードの妥当性
- [ ] Clippy警告の確認
```
## Agent仕様
`agents/specs/` にカスタムAgent仕様を定義。
### 例: コードリファクタリングAgent
`agents/specs/refactor-agent.yaml`:
```yaml
name: refactor-agent
version: "1.0"
description: コードリファクタリング専門Agent
capabilities:
- code_analysis
- refactoring
- testing
preferences:
style: functional
error_handling: result
max_iterations: 10
```
## セッション管理
### コマンドライン操作
```bash
# セッション一覧
miyabi sessions
# 特定セッションで再開
miyabi --session <session-id>
# Markdownエクスポート
miyabi sessions -m <session-id>
# 削除
# JSONエクスポート
miyabi sessions -e <session-id>
# セッション削除
miyabi sessions -d <session-id>
```
### セッションファイル形式
セッションは `sessions/` に JSON 形式で保存:
```json
{
"id": "uuid",
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T01:00:00Z",
"model": "claude-sonnet-4-5-20250929",
"messages": [...]
}
```
## プロジェクトルール (.miyabirules)
プロジェクトルートに `.miyabirules` ファイルを配置して、プロジェクト固有のルールを定義:
```yaml
version: 1
rules:
- name: "no-unwrap"
pattern: ".unwrap()"
suggestion: "Use ? operator or proper error handling"
file_extensions: ["rs"]
severity: "warning"
enabled: true
- name: "no-println-debug"
pattern: "println!"
suggestion: "Use tracing macros for logging"
file_extensions: ["rs"]
severity: "info"
agent_preferences:
codegen:
style: "functional"
error_handling: "result"
min_score: 80
clippy_strict: true
settings:
auto_format: true
max_file_size: 1048576
```
## 環境変数
設定ファイルの値は環境変数でオーバーライド可能:
```bash
# 必須
export ANTHROPIC_API_KEY="sk-ant-..."
# オプション
export MIYABI_MODEL="claude-sonnet-4-5-20250929"
export MIYABI_MAX_TOKENS="16384"
export MIYABI_THINKING="true"
export MIYABI_THEME="dracula"
```
## Core Library機能の利用
### キャッシュ
LLMレスポンスのキャッシュで高速化:
```rust
use miyabi_core::{create_llm_cache, LLMCacheKey};
let cache = create_llm_cache(); // 1時間TTL
```
### フィーチャーフラグ
機能の段階的ロールアウト:
```rust
use miyabi_core::FeatureFlagManager;
let flags = FeatureFlagManager::new();
flags.set_flag("new_ui", true);
```
### Circuit Breaker
API障害時の保護:
```rust
use miyabi_core::CircuitBreaker;
let breaker = CircuitBreaker::default();
// 5回失敗で開く、60秒後に半開
```
### プラグイン
カスタム機能の追加:
```rust
use miyabi_core::{Plugin, PluginManager};
let manager = PluginManager::new();
manager.register(Box::new(MyPlugin))?;
```
## トラブルシューティング
### API接続エラー
1. API Keyの確認: `echo $ANTHROPIC_API_KEY`
2. ネットワーク接続確認
3. タイムアウト値の増加
### セッション破損
1. `sessions/` から該当ファイルを削除
2. `miyabi sessions` で確認
### 設定が反映されない
1. 設定ファイルの構文確認: `cat config.toml`
2. 環境変数の確認
3. CLIオプションの優先順位確認
## 使い方
```bash
@ -61,6 +320,9 @@ miyabi
# バージョン情報
miyabi version
# ステータス確認
miyabi status
# ヘルプ
miyabi --help
```

View file

@ -0,0 +1,38 @@
# Code Explanation
Provide a detailed explanation of the specified code:
## Analysis Sections
### 1. Overview
- Purpose and responsibility
- Where it fits in the architecture
- Key dependencies
### 2. Data Flow
- Inputs and outputs
- Data transformations
- Side effects
### 3. Key Concepts
- Design patterns used
- Algorithms implemented
- Important abstractions
### 4. Line-by-Line Breakdown
For complex sections, explain:
- What each block does
- Why it's implemented this way
- Any non-obvious behavior
### 5. Usage Examples
```rust
// Example of how to use this code
```
### 6. Potential Improvements
- Suggestions for enhancement
- Known limitations
- Future considerations
Target audience: Developers new to this codebase.

View file

@ -0,0 +1,20 @@
# Code Refactoring
Refactor the specified code with the following goals:
1. **Improve Readability** - Clear naming, logical structure, remove complexity
2. **Reduce Duplication** - Extract common patterns into reusable functions
3. **Enhance Maintainability** - Better separation of concerns, SOLID principles
4. **Optimize Performance** - Only if it doesn't sacrifice readability
Guidelines:
- Preserve existing behavior (no functional changes)
- Keep existing tests passing
- Add comments only where logic isn't self-evident
- Prefer small, focused functions over large ones
Output format:
1. Analysis of current issues
2. Refactoring plan
3. Refactored code with explanations
4. Summary of improvements

View file

@ -0,0 +1,17 @@
# Code Review
Please review the provided code for:
1. **Bugs & Logic Errors** - Identify potential bugs, edge cases, and logical issues
2. **Performance** - Spot inefficient algorithms, unnecessary allocations, or N+1 queries
3. **Security** - Check for vulnerabilities like injection, XSS, or unsafe operations
4. **Code Style** - Ensure consistency with project conventions
5. **Error Handling** - Verify proper error propagation and user-friendly messages
For each issue found, provide:
- Location (file:line)
- Severity (Critical/High/Medium/Low)
- Description
- Suggested fix with code example
End with a summary score (1-10) and overall assessment.

33
.miyabi/commands/test.md Normal file
View file

@ -0,0 +1,33 @@
# Test Generation
Generate comprehensive tests for the specified code:
## Test Types
1. **Unit Tests** - Test individual functions in isolation
2. **Integration Tests** - Test component interactions
3. **Edge Cases** - Boundary conditions, empty inputs, max values
4. **Error Cases** - Invalid inputs, failure scenarios
## Requirements
- Use the project's testing framework
- Follow Arrange-Act-Assert pattern
- Descriptive test names that explain the scenario
- Mock external dependencies
- Aim for high coverage of critical paths
## Output Format
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_function_name_scenario() {
// Arrange
// Act
// Assert
}
}
```
Include explanation of test strategy and coverage goals.

View file

@ -0,0 +1,41 @@
# Rust Best Practices Checklist
When writing or reviewing Rust code, ensure:
## Error Handling
- [ ] Use `Result<T, E>` for fallible operations
- [ ] Avoid `.unwrap()` and `.expect()` in library code
- [ ] Use `?` operator for error propagation
- [ ] Create custom error types with `thiserror`
- [ ] Provide context with `.context()` or `.with_context()`
## Memory & Performance
- [ ] Prefer `&str` over `String` for function parameters
- [ ] Use `Cow<str>` when ownership is conditional
- [ ] Avoid unnecessary cloning
- [ ] Use iterators instead of index loops
- [ ] Consider `Vec::with_capacity()` for known sizes
## Safety
- [ ] Minimize `unsafe` code
- [ ] Document safety invariants for `unsafe` blocks
- [ ] Validate all external input
- [ ] Use `#[must_use]` for important return values
## Async
- [ ] Use `tokio::spawn` for concurrent tasks
- [ ] Avoid blocking operations in async context
- [ ] Use `tokio::select!` for racing futures
- [ ] Handle cancellation properly
## Testing
- [ ] Unit tests in same file with `#[cfg(test)]`
- [ ] Integration tests in `tests/` directory
- [ ] Use `#[should_panic]` for expected panics
- [ ] Test error cases, not just happy paths
## Documentation
- [ ] Doc comments for public items (`///`)
- [ ] Examples in doc comments
- [ ] Module-level documentation (`//!`)
- [ ] Link related items with `[`item`]`

76
.miyabirules Normal file
View file

@ -0,0 +1,76 @@
# Miyabi CLI Project Rules
# This file defines project-specific rules and agent preferences
version: 1
rules:
# Rust best practices
- name: "no-unwrap"
pattern: ".unwrap()"
suggestion: "Use ? operator or proper error handling instead of unwrap()"
file_extensions: ["rs"]
severity: "warning"
enabled: true
- name: "no-expect"
pattern: ".expect("
suggestion: "Consider using ? operator or match for better error messages"
file_extensions: ["rs"]
severity: "info"
enabled: true
- name: "no-println-debug"
pattern: "println!"
suggestion: "Use tracing macros (info!, debug!, error!) for logging"
file_extensions: ["rs"]
severity: "info"
enabled: true
- name: "no-dbg"
pattern: "dbg!"
suggestion: "Remove debug macro before committing"
file_extensions: ["rs"]
severity: "warning"
enabled: true
- name: "no-todo"
pattern: "TODO"
suggestion: "Address TODO comments or create GitHub issues"
file_extensions: ["rs", "ts", "js", "md"]
severity: "info"
enabled: true
- name: "no-fixme"
pattern: "FIXME"
suggestion: "Address FIXME comments before merging"
file_extensions: ["rs", "ts", "js"]
severity: "warning"
enabled: true
# Agent-specific preferences
agent_preferences:
codegen:
style: "functional"
error_handling: "result"
min_score: 80
clippy_strict: true
review:
focus_areas:
- "error_handling"
- "performance"
- "security"
min_score: 70
refactor:
preserve_tests: true
max_changes_per_file: 100
# Global settings
settings:
auto_format: true
max_file_size: 1048576
ignore_patterns:
- "target/"
- "node_modules/"
- ".git/"

102
Cargo.lock generated
View file

@ -198,6 +198,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@ -745,6 +747,21 @@ dependencies = [
"wasip2",
]
[[package]]
name = "git2"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b903b73e45dc0c6c596f2d37eccece7c1c8bb6e4407b001096387c63d0d93724"
dependencies = [
"bitflags",
"libc",
"libgit2-sys",
"log",
"openssl-probe",
"openssl-sys",
"url",
]
[[package]]
name = "glob"
version = "0.3.3"
@ -1139,6 +1156,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.82"
@ -1161,6 +1188,20 @@ version = "0.2.177"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
[[package]]
name = "libgit2-sys"
version = "0.17.0+1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10472326a8a6477c3c20a64547b0059e4b0d086869eee31e6d7da728a8eb7224"
dependencies = [
"cc",
"libc",
"libssh2-sys",
"libz-sys",
"openssl-sys",
"pkg-config",
]
[[package]]
name = "libredox"
version = "0.1.10"
@ -1171,6 +1212,32 @@ dependencies = [
"libc",
]
[[package]]
name = "libssh2-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9"
dependencies = [
"cc",
"libc",
"libz-sys",
"openssl-sys",
"pkg-config",
"vcpkg",
]
[[package]]
name = "libz-sys"
version = "1.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linked-hash-map"
version = "0.5.6"
@ -1293,16 +1360,19 @@ dependencies = [
"chrono",
"dirs",
"futures",
"git2",
"glob",
"regex",
"reqwest",
"serde",
"serde_json",
"serde_yaml",
"tempfile",
"thiserror 2.0.17",
"tokio",
"toml",
"tracing",
"tracing-subscriber",
"uuid",
]
@ -2007,6 +2077,19 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@ -2522,6 +2605,16 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-serde"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
dependencies = [
"serde",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.20"
@ -2532,12 +2625,15 @@ dependencies = [
"nu-ansi-term",
"once_cell",
"regex-automata",
"serde",
"serde_json",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
"tracing-serde",
]
[[package]]
@ -2593,6 +2689,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"

BIN
MIYABI.md

Binary file not shown.

197
README.md
View file

@ -166,6 +166,191 @@ Agent mode features:
- Iteration limits for safety
- JSON output format option
## Core Library Features
The `miyabi-core` crate provides powerful utilities for building AI applications.
### Git Utilities
```rust
use miyabi_core::{find_git_root, get_current_branch, is_in_git_repo};
// Find repository root
let root = find_git_root()?;
// Get current branch
let branch = get_current_branch(&root)?;
// Check if in git repo
if is_in_git_repo(&path) {
println!("In a git repository");
}
```
### Logger System
```rust
use miyabi_core::{init_logger, LogLevel, LogFormat};
// Initialize with defaults
init_logger();
// Or with custom config
use miyabi_core::LoggerConfig;
let config = LoggerConfig {
level: LogLevel::Debug,
format: LogFormat::Json,
..Default::default()
};
init_logger_with_config(config);
```
### Retry with Backoff
```rust
use miyabi_core::{retry_with_backoff, BackoffRetryConfig};
let config = BackoffRetryConfig::default();
let result = retry_with_backoff(config, || async {
// Your operation that might fail
make_api_call().await
}).await?;
```
### Circuit Breaker
```rust
use miyabi_core::{CircuitBreaker, CircuitState};
let breaker = CircuitBreaker::default();
let result = breaker.call(|| {
Box::pin(async {
// Your operation
Ok::<_, std::io::Error>(())
})
}).await;
// Check state
match breaker.state().await {
CircuitState::Closed => println!("Normal operation"),
CircuitState::Open => println!("Circuit open - blocking calls"),
CircuitState::HalfOpen => println!("Testing recovery"),
}
```
### Rules System (.miyabirules)
Support for project-specific rules via `.miyabirules` files:
```yaml
# .miyabirules
version: 1
rules:
- name: "no-unwrap"
pattern: ".unwrap()"
suggestion: "Use ? operator or proper error handling"
file_extensions: ["rs"]
severity: "warning"
agent_preferences:
codegen:
style: "functional"
error_handling: "result"
```
```rust
use miyabi_core::{RulesLoader, MiyabiRules};
let loader = RulesLoader::new(project_root);
let rules = loader.load_or_default()?;
// Get rules for a file
let applicable = rules.rules_for_file(Path::new("src/main.rs"));
```
### Cache System
```rust
use miyabi_core::{create_llm_cache, create_api_cache, LLMCacheKey};
// LLM response cache (1 hour TTL)
let cache = create_llm_cache();
let key = LLMCacheKey::new("prompt", "model", Some(0.7));
cache.insert(key.clone(), "response".to_string()).await;
let cached = cache.get(&key).await;
// API cache (30 min TTL)
let api_cache = create_api_cache();
```
### Plugin System
```rust
use miyabi_core::{Plugin, PluginManager, PluginMetadata, PluginContext, PluginResult};
use anyhow::Result;
struct MyPlugin;
impl Plugin for MyPlugin {
fn metadata(&self) -> PluginMetadata {
PluginMetadata {
name: "my-plugin".to_string(),
version: "1.0.0".to_string(),
description: Some("My custom plugin".to_string()),
author: None,
}
}
fn init(&mut self) -> Result<()> {
Ok(())
}
fn execute(&self, ctx: &PluginContext) -> Result<PluginResult> {
Ok(PluginResult {
success: true,
message: Some("Done".to_string()),
data: None,
})
}
}
// Register and execute
let manager = PluginManager::new();
manager.register(Box::new(MyPlugin))?;
let result = manager.execute("my-plugin", &PluginContext::default())?;
```
### Feature Flags
```rust
use miyabi_core::{FeatureFlagManager, FeatureFlag};
let flags = FeatureFlagManager::new();
// Simple flag
flags.set_flag("new_feature", true);
if flags.is_enabled("new_feature") {
// Use new feature
}
// With rollout percentage
flags.set_flag_with_options(
"beta_feature",
true,
Some("Beta testing".to_string()),
Some(0.5), // 50% rollout
);
// Load from config
use std::collections::HashMap;
let mut config = HashMap::new();
config.insert("flag1".to_string(), true);
flags.load_from_map(config);
```
## Project Structure
```
@ -173,9 +358,19 @@ miyabi-cli-standalone/
├── crates/
│ ├── miyabi-cli/ # CLI entry point
│ ├── miyabi-core/ # Core library
│ │ ├── agent/ # Agent system
│ │ ├── agent.rs # Agent system
│ │ ├── anthropic.rs # Anthropic API client
│ │ ├── cache.rs # TTL cache system
│ │ ├── config.rs # Configuration
│ │ ├── error_policy.rs # Circuit breaker
│ │ ├── feature_flags.rs # Feature flag management
│ │ ├── git.rs # Git utilities
│ │ ├── logger.rs # Logging system
│ │ ├── plugin.rs # Plugin system
│ │ ├── retry.rs # Retry with backoff
│ │ ├── rules.rs # .miyabirules support
│ │ ├── session.rs # Session management
│ │ ├── tools.rs # Built-in tools
│ │ └── ...
│ └── miyabi-tui/ # TUI implementation
│ ├── app.rs # Main application

View file

@ -1,9 +1,26 @@
//! Miyabi CLI - Main entry point
use clap::{Parser, Subcommand};
use miyabi_core::{FeatureFlagManager, RulesLoader};
use std::path::PathBuf;
use tracing_subscriber::EnvFilter;
/// Global feature flags manager
static FEATURE_FLAGS: std::sync::OnceLock<FeatureFlagManager> = std::sync::OnceLock::new();
/// Get the global feature flags manager
pub fn feature_flags() -> &'static FeatureFlagManager {
FEATURE_FLAGS.get_or_init(|| {
let manager = FeatureFlagManager::new();
// Default feature flags
manager.set_flag("extended_thinking", true);
manager.set_flag("auto_save_sessions", true);
manager.set_flag("syntax_highlighting", true);
manager.set_flag("vim_mode", false);
manager
})
}
#[derive(Parser)]
#[command(name = "miyabi")]
#[command(author, version, about = "Miyabi - Autonomous AI Development Framework", long_about = None)]
@ -54,6 +71,12 @@ enum Commands {
},
/// Show version and system information
Version,
/// Show project rules (.miyabirules)
Rules {
/// Show detailed rule information
#[arg(short, long)]
verbose: bool,
},
/// Run agent with a prompt (autonomous execution)
Agent {
/// The prompt to execute
@ -145,11 +168,40 @@ async fn main() -> anyhow::Result<()> {
}
}
Some(Commands::Status) => {
use miyabi_core::config::Config;
let config = Config::load().unwrap_or_default();
println!("Miyabi Status: Ready");
println!(
"Config path: {:?}",
miyabi_core::config::Config::default_path()
);
println!();
println!("Config: {}", Config::default_path().display());
println!("Sessions: {}", config.sessions_dir().display());
println!("Model: {}", config.api.model);
println!();
// Load and show rules info
let cwd = std::env::current_dir().unwrap_or_default();
let loader = RulesLoader::new(cwd);
match loader.load() {
Ok(Some(rules)) => {
println!("Rules: {} rules loaded", rules.rules.len());
if !rules.agent_preferences.is_empty() {
println!("Agents: {} agent preferences", rules.agent_preferences.len());
}
}
Ok(None) => {
println!("Rules: No .miyabirules found");
}
Err(e) => {
println!("Rules: Error loading - {}", e);
}
}
// Show feature flags
let flags = feature_flags();
let all_flags = flags.get_all_flags();
let enabled_count = all_flags.iter().filter(|f| f.enabled).count();
println!("Flags: {}/{} enabled", enabled_count, all_flags.len());
}
Some(Commands::Init) => {
use miyabi_core::config::Config;
@ -284,6 +336,78 @@ async fn main() -> anyhow::Result<()> {
std::env::consts::ARCH
);
}
Some(Commands::Rules { verbose }) => {
let cwd = std::env::current_dir().unwrap_or_default();
let loader = RulesLoader::new(cwd.clone());
match loader.load() {
Ok(Some(rules)) => {
println!("Project Rules (.miyabirules)");
println!("============================");
println!();
if rules.rules.is_empty() {
println!("No rules defined.");
} else {
println!("Rules ({}):", rules.rules.len());
for rule in &rules.rules {
let status = if rule.enabled { "" } else { "" };
let severity = match rule.severity.as_str() {
"error" => "🔴",
"warning" => "🟡",
_ => "🔵",
};
println!(" {} {} {} - {}", status, severity, rule.name, rule.suggestion);
if verbose {
if let Some(pattern) = &rule.pattern {
println!(" Pattern: {}", pattern);
}
if !rule.file_extensions.is_empty() {
println!(" Extensions: {}", rule.file_extensions.join(", "));
}
println!();
}
}
}
if !rules.agent_preferences.is_empty() {
println!();
println!("Agent Preferences ({}):", rules.agent_preferences.len());
for (agent, prefs) in &rules.agent_preferences {
println!(" {}:", agent);
if let Some(style) = &prefs.style {
println!(" Style: {}", style);
}
if let Some(handling) = &prefs.error_handling {
println!(" Error Handling: {}", handling);
}
if let Some(score) = prefs.min_score {
println!(" Min Score: {}", score);
}
}
}
if verbose && !rules.settings.is_empty() {
println!();
println!("Settings:");
for (key, value) in &rules.settings {
println!(" {}: {}", key, value);
}
}
}
Ok(None) => {
println!("No .miyabirules file found in {} or parent directories.", cwd.display());
println!();
println!("Create a .miyabirules file to define project-specific rules.");
println!("See: miyabi --help for more information.");
}
Err(e) => {
eprintln!("Error loading rules: {}", e);
std::process::exit(1);
}
}
}
Some(Commands::Agent {
prompt,
max_iterations,

View file

@ -16,6 +16,7 @@ serde_json = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
chrono = { workspace = true }
uuid = { workspace = true }
reqwest = { workspace = true }
@ -23,6 +24,8 @@ futures = { workspace = true }
async-trait = { workspace = true }
glob = { workspace = true }
regex = { workspace = true }
git2 = "0.19"
serde_yaml = "0.9"
dirs = "5"
toml = "0.8"

View file

@ -0,0 +1,240 @@
//! Caching utilities for Miyabi
//!
//! Provides in-memory caching with TTL support for LLM responses and other expensive operations
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::hash::Hash;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
/// Cache entry with TTL support
#[derive(Debug, Clone)]
pub struct CacheEntry<T> {
/// Cached value
pub value: T,
/// Expiration timestamp
pub expires_at: Instant,
}
impl<T> CacheEntry<T> {
/// Create a new cache entry with the given TTL
pub fn new(value: T, ttl: Duration) -> Self {
Self {
value,
expires_at: Instant::now() + ttl,
}
}
/// Check if the cache entry has expired
pub fn is_expired(&self) -> bool {
Instant::now() > self.expires_at
}
}
/// Thread-safe TTL cache
#[derive(Debug)]
pub struct TTLCache<K, V> {
inner: Arc<RwLock<HashMap<K, CacheEntry<V>>>>,
default_ttl: Duration,
}
impl<K, V> TTLCache<K, V>
where
K: Hash + Eq + Clone + Send + Sync + 'static,
V: Clone + Send + Sync + 'static,
{
/// Create a new TTL cache with default TTL
pub fn new(default_ttl: Duration) -> Self {
Self {
inner: Arc::new(RwLock::new(HashMap::new())),
default_ttl,
}
}
/// Get a value from the cache
pub async fn get(&self, key: &K) -> Option<V> {
let mut cache = self.inner.write().await;
if let Some(entry) = cache.get(key) {
if entry.is_expired() {
cache.remove(key);
None
} else {
Some(entry.value.clone())
}
} else {
None
}
}
/// Insert a value into the cache with default TTL
pub async fn insert(&self, key: K, value: V) {
self.insert_with_ttl(key, value, self.default_ttl).await;
}
/// Insert a value into the cache with custom TTL
pub async fn insert_with_ttl(&self, key: K, value: V, ttl: Duration) {
let mut cache = self.inner.write().await;
cache.insert(key, CacheEntry::new(value, ttl));
}
/// Remove a value from the cache
pub async fn remove(&self, key: &K) -> Option<V> {
let mut cache = self.inner.write().await;
cache.remove(key).map(|entry| entry.value)
}
/// Clear all expired entries
pub async fn cleanup_expired(&self) -> usize {
let mut cache = self.inner.write().await;
let initial_size = cache.len();
cache.retain(|_, entry| !entry.is_expired());
initial_size - cache.len()
}
/// Get cache statistics
pub async fn stats(&self) -> CacheStats {
let cache = self.inner.read().await;
let total_entries = cache.len();
let expired_entries = cache.values().filter(|entry| entry.is_expired()).count();
CacheStats {
total_entries,
expired_entries,
active_entries: total_entries - expired_entries,
}
}
/// Clear all entries
pub async fn clear(&self) {
let mut cache = self.inner.write().await;
cache.clear();
}
}
/// Cache statistics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheStats {
/// Total entries
pub total_entries: usize,
/// Expired entries
pub expired_entries: usize,
/// Active entries
pub active_entries: usize,
}
/// LLM response cache key
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
pub struct LLMCacheKey {
/// Prompt hash
pub prompt_hash: String,
/// Model name
pub model: String,
/// Temperature parameter (as bits to avoid f32 Hash/Eq issues)
pub temperature: Option<u32>,
}
impl LLMCacheKey {
pub fn new(prompt: &str, model: &str, temperature: Option<f32>) -> Self {
use std::collections::hash_map::DefaultHasher;
use std::hash::Hasher;
let mut hasher = DefaultHasher::new();
prompt.hash(&mut hasher);
model.hash(&mut hasher);
if let Some(temp) = temperature {
temp.to_bits().hash(&mut hasher);
}
Self {
prompt_hash: format!("{:x}", hasher.finish()),
model: model.to_string(),
temperature: temperature.map(|t| t.to_bits()),
}
}
}
/// LLM response cache
pub type LLMCache = TTLCache<LLMCacheKey, String>;
/// Create a new LLM cache with 1 hour TTL
pub fn create_llm_cache() -> LLMCache {
TTLCache::new(Duration::from_secs(3600))
}
/// API response cache key
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
pub struct ApiCacheKey {
/// Endpoint
pub endpoint: String,
/// Request hash
pub request_hash: String,
}
impl ApiCacheKey {
pub fn new(endpoint: &str, request_body: &str) -> Self {
use std::collections::hash_map::DefaultHasher;
use std::hash::Hasher;
let mut hasher = DefaultHasher::new();
request_body.hash(&mut hasher);
Self {
endpoint: endpoint.to_string(),
request_hash: format!("{:x}", hasher.finish()),
}
}
}
/// API response cache
pub type ApiCache = TTLCache<ApiCacheKey, String>;
/// Create a new API cache with 30 minutes TTL
pub fn create_api_cache() -> ApiCache {
TTLCache::new(Duration::from_secs(1800))
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::time::sleep;
#[tokio::test]
async fn test_ttl_cache_basic_operations() {
let cache = TTLCache::new(Duration::from_millis(100));
cache.insert("key1", "value1").await;
assert_eq!(cache.get(&"key1").await, Some("value1"));
sleep(Duration::from_millis(150)).await;
assert_eq!(cache.get(&"key1").await, None);
}
#[tokio::test]
async fn test_ttl_cache_cleanup() {
let cache = TTLCache::new(Duration::from_millis(50));
cache.insert("key1", "value1").await;
cache.insert("key2", "value2").await;
sleep(Duration::from_millis(100)).await;
let cleaned = cache.cleanup_expired().await;
assert_eq!(cleaned, 2);
let stats = cache.stats().await;
assert_eq!(stats.total_entries, 0);
}
#[tokio::test]
async fn test_llm_cache_key() {
let key1 = LLMCacheKey::new("prompt1", "model1", Some(0.7));
let key2 = LLMCacheKey::new("prompt1", "model1", Some(0.7));
let key3 = LLMCacheKey::new("prompt2", "model1", Some(0.7));
assert_eq!(key1, key2);
assert_ne!(key1, key3);
}
}

View file

@ -16,8 +16,85 @@ pub enum Error {
#[error("Agent error: {0}")]
Agent(String),
#[error("Git error: {0}")]
Git(String),
#[error("HTTP error: {0}")]
Http(String),
#[error("Timeout after {0}ms")]
Timeout(u64),
#[error("GitHub error: {0}")]
GitHub(String),
#[error("Validation error: {0}")]
Validation(String),
#[error("Authentication error: {0}")]
Auth(String),
#[error("Tool error: {0}")]
Tool(String),
#[error("Permission denied: {0}")]
PermissionDenied(String),
#[error("{0}")]
Other(String),
}
pub type Result<T> = std::result::Result<T, Error>;
/// Check if an error is retryable (transient)
impl Error {
pub fn is_retryable(&self) -> bool {
match self {
// Always retryable
Error::Timeout(_) => true,
// HTTP errors - check message for transient patterns
Error::Http(msg) => {
let lower = msg.to_lowercase();
lower.contains("timeout")
|| lower.contains("connection")
|| lower.contains("temporarily")
}
// GitHub errors - check for rate limiting
Error::GitHub(msg) => {
let lower = msg.to_lowercase();
lower.contains("rate limit")
|| lower.contains("retry")
|| lower.contains("temporarily unavailable")
}
// IO errors - some kinds are retryable
Error::Io(io_error) => matches!(
io_error.kind(),
std::io::ErrorKind::ConnectionRefused
| std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::ConnectionAborted
| std::io::ErrorKind::TimedOut
| std::io::ErrorKind::Interrupted
| std::io::ErrorKind::WouldBlock
),
// Git errors - check message for lock conflicts
Error::Git(msg) => {
let lower = msg.to_lowercase();
lower.contains("lock") || lower.contains("unable to create")
}
// Never retryable
Error::Agent(_) => false,
Error::Auth(_) => false,
Error::Config(_) => false,
Error::Validation(_) => false,
Error::Json(_) => false,
Error::Tool(_) => false,
Error::PermissionDenied(_) => false,
Error::Other(_) => false,
}
}
}

View file

@ -0,0 +1,291 @@
//! Error handling policies for Miyabi system
//!
//! This module provides advanced error handling strategies:
//! - Circuit Breaker pattern for preventing cascading failures
//! - Fallback strategies for graceful degradation
use crate::error::Error;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
/// Fallback strategy when execution fails
#[derive(Debug, Clone)]
pub enum FallbackStrategy {
/// Accept partial success
AcceptPartialSuccess {
/// Minimum number of successful operations required
min_successful: usize,
},
/// Retry with lower temperature
RetryWithLowerTemperature {
/// Amount to reduce temperature by
temperature_reduction: f64,
},
/// Switch to a different LLM model
SwitchModel {
/// Fallback model name
fallback_model: String,
},
/// Wait for human intervention
WaitForHumanIntervention {
/// Timeout before giving up
timeout: Duration,
},
/// Skip the task entirely
SkipTask,
}
impl Default for FallbackStrategy {
fn default() -> Self {
Self::AcceptPartialSuccess { min_successful: 1 }
}
}
impl FallbackStrategy {
/// Creates a partial success strategy with default threshold
pub fn partial_success() -> Self {
Self::AcceptPartialSuccess { min_successful: 1 }
}
/// Creates a temperature reduction strategy
pub fn lower_temperature() -> Self {
Self::RetryWithLowerTemperature {
temperature_reduction: 0.2,
}
}
/// Creates a model switch strategy
pub fn switch_to_claude() -> Self {
Self::SwitchModel {
fallback_model: "claude-sonnet-4-5-20250929".to_string(),
}
}
/// Creates a human intervention strategy
pub fn wait_for_human() -> Self {
Self::WaitForHumanIntervention {
timeout: Duration::from_secs(24 * 60 * 60),
}
}
}
/// State of the circuit breaker
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CircuitState {
/// Circuit is closed - requests flow normally
Closed,
/// Circuit is open - requests are blocked
Open,
/// Circuit is half-open - testing if service recovered
HalfOpen,
}
/// Circuit breaker for preventing cascading failures
///
/// The circuit breaker pattern prevents repeated attempts to execute
/// operations that are likely to fail, allowing the system to recover.
pub struct CircuitBreaker {
/// Number of consecutive failures before opening circuit
failure_threshold: usize,
/// Number of consecutive successes before closing circuit
success_threshold: usize,
/// Duration to wait before transitioning from Open to HalfOpen
timeout: Duration,
/// Current circuit state
state: Arc<Mutex<CircuitState>>,
/// Count of consecutive failures
consecutive_failures: Arc<Mutex<usize>>,
/// Count of consecutive successes
consecutive_successes: Arc<Mutex<usize>>,
/// Time when circuit was opened
opened_at: Arc<Mutex<Option<Instant>>>,
}
impl CircuitBreaker {
/// Creates a new CircuitBreaker
pub fn new(failure_threshold: usize, success_threshold: usize, timeout: Duration) -> Self {
Self {
failure_threshold,
success_threshold,
timeout,
state: Arc::new(Mutex::new(CircuitState::Closed)),
consecutive_failures: Arc::new(Mutex::new(0)),
consecutive_successes: Arc::new(Mutex::new(0)),
opened_at: Arc::new(Mutex::new(None)),
}
}
/// Creates a CircuitBreaker with default settings
pub fn default_config() -> Self {
Self::new(5, 2, Duration::from_secs(60))
}
/// Executes the given operation through the circuit breaker
pub async fn call<F, T, E>(&self, operation: F) -> Result<T, Error>
where
F: FnOnce() -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<T, E>> + Send>>,
E: std::error::Error + Send + Sync + 'static,
{
// Check if we should attempt reset
if self.should_attempt_reset().await {
*self.state.lock().await = CircuitState::HalfOpen;
}
let current_state = *self.state.lock().await;
match current_state {
CircuitState::Open => Err(Error::Other("Circuit breaker is open".to_string())),
CircuitState::Closed | CircuitState::HalfOpen => match operation().await {
Ok(result) => {
self.on_success().await;
Ok(result)
}
Err(e) => {
self.on_failure().await;
Err(Error::Other(e.to_string()))
}
},
}
}
/// Records a successful operation
async fn on_success(&self) {
let mut successes = self.consecutive_successes.lock().await;
*successes += 1;
*self.consecutive_failures.lock().await = 0;
if *successes >= self.success_threshold {
let mut state = self.state.lock().await;
if *state != CircuitState::Closed {
*state = CircuitState::Closed;
*self.opened_at.lock().await = None;
}
*successes = 0;
}
}
/// Records a failed operation
async fn on_failure(&self) {
let mut failures = self.consecutive_failures.lock().await;
*failures += 1;
*self.consecutive_successes.lock().await = 0;
if *failures >= self.failure_threshold {
let mut state = self.state.lock().await;
if *state == CircuitState::Closed {
*state = CircuitState::Open;
*self.opened_at.lock().await = Some(Instant::now());
}
}
}
/// Checks if circuit should attempt reset
async fn should_attempt_reset(&self) -> bool {
let state = *self.state.lock().await;
if state != CircuitState::Open {
return false;
}
let opened_at = self.opened_at.lock().await;
if let Some(opened_time) = *opened_at {
opened_time.elapsed() >= self.timeout
} else {
false
}
}
/// Gets the current circuit state
pub async fn state(&self) -> CircuitState {
*self.state.lock().await
}
/// Gets the number of consecutive failures
pub async fn consecutive_failures(&self) -> usize {
*self.consecutive_failures.lock().await
}
/// Gets the number of consecutive successes
pub async fn consecutive_successes(&self) -> usize {
*self.consecutive_successes.lock().await
}
/// Resets the circuit breaker to closed state
pub async fn reset(&self) {
*self.state.lock().await = CircuitState::Closed;
*self.consecutive_failures.lock().await = 0;
*self.consecutive_successes.lock().await = 0;
*self.opened_at.lock().await = None;
}
}
impl Default for CircuitBreaker {
fn default() -> Self {
Self::default_config()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_circuit_breaker_opens_after_failures() {
let breaker = CircuitBreaker::new(3, 2, Duration::from_millis(100));
assert_eq!(breaker.state().await, CircuitState::Closed);
for _ in 0..3 {
let result = breaker
.call(|| {
Box::pin(async {
Result::<(), std::io::Error>::Err(std::io::Error::other("test error"))
})
})
.await;
assert!(result.is_err());
}
assert_eq!(breaker.state().await, CircuitState::Open);
}
#[tokio::test]
async fn test_circuit_breaker_blocks_when_open() {
let breaker = CircuitBreaker::new(2, 2, Duration::from_secs(60));
for _ in 0..2 {
let _ = breaker
.call(|| {
Box::pin(async {
Result::<(), std::io::Error>::Err(std::io::Error::other("error"))
})
})
.await;
}
assert_eq!(breaker.state().await, CircuitState::Open);
let result = breaker
.call(|| Box::pin(async { Ok::<(), std::io::Error>(()) }))
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_circuit_breaker_reset() {
let breaker = CircuitBreaker::new(2, 2, Duration::from_secs(60));
for _ in 0..2 {
let _ = breaker
.call(|| {
Box::pin(async {
Result::<(), std::io::Error>::Err(std::io::Error::other("error"))
})
})
.await;
}
assert_eq!(breaker.state().await, CircuitState::Open);
breaker.reset().await;
assert_eq!(breaker.state().await, CircuitState::Closed);
}
}

View file

@ -0,0 +1,330 @@
//! Feature Flag Management System
//!
//! Provides a flexible feature flag system for gradual rollout and A/B testing.
//!
//! # Examples
//!
//! ```rust
//! use miyabi_core::feature_flags::FeatureFlagManager;
//!
//! let manager = FeatureFlagManager::new();
//! manager.set_flag("new_architecture", true);
//!
//! if manager.is_enabled("new_architecture") {
//! // Use new architecture
//! } else {
//! // Use old architecture
//! }
//! ```
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
/// Feature flag configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeatureFlag {
/// Flag name
pub name: String,
/// Enabled status
pub enabled: bool,
/// Optional description
pub description: Option<String>,
/// Optional rollout percentage (0.0 to 1.0)
pub rollout_percentage: Option<f64>,
}
/// Feature flag manager for controlling feature rollout
#[derive(Debug, Clone)]
pub struct FeatureFlagManager {
/// Internal flag storage (thread-safe)
flags: Arc<RwLock<HashMap<String, FeatureFlag>>>,
}
impl Default for FeatureFlagManager {
fn default() -> Self {
Self::new()
}
}
impl FeatureFlagManager {
/// Create a new feature flag manager
pub fn new() -> Self {
Self {
flags: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Check if a feature flag is enabled
///
/// # Arguments
///
/// * `flag_name` - Name of the feature flag
///
/// # Returns
///
/// `true` if the flag exists and is enabled, `false` otherwise
pub fn is_enabled(&self, flag_name: &str) -> bool {
let flags = self.flags.read().unwrap();
flags.get(flag_name).map(|flag| flag.enabled).unwrap_or(false)
}
/// Set a feature flag
///
/// # Arguments
///
/// * `flag_name` - Name of the feature flag
/// * `enabled` - Whether the flag should be enabled
pub fn set_flag(&self, flag_name: impl Into<String>, enabled: bool) {
let mut flags = self.flags.write().unwrap();
let name = flag_name.into();
flags.insert(
name.clone(),
FeatureFlag {
name,
enabled,
description: None,
rollout_percentage: None,
},
);
}
/// Set a feature flag with description and rollout percentage
///
/// # Arguments
///
/// * `flag_name` - Name of the feature flag
/// * `enabled` - Whether the flag should be enabled
/// * `description` - Optional description of the flag
/// * `rollout_percentage` - Optional rollout percentage (0.0 to 1.0)
pub fn set_flag_with_options(
&self,
flag_name: impl Into<String>,
enabled: bool,
description: Option<String>,
rollout_percentage: Option<f64>,
) {
let mut flags = self.flags.write().unwrap();
let name = flag_name.into();
flags.insert(
name.clone(),
FeatureFlag {
name,
enabled,
description,
rollout_percentage,
},
);
}
/// Remove a feature flag
///
/// # Arguments
///
/// * `flag_name` - Name of the feature flag to remove
pub fn remove_flag(&self, flag_name: &str) {
let mut flags = self.flags.write().unwrap();
flags.remove(flag_name);
}
/// Get all feature flags
///
/// # Returns
///
/// A vector of all feature flags
pub fn get_all_flags(&self) -> Vec<FeatureFlag> {
let flags = self.flags.read().unwrap();
flags.values().cloned().collect()
}
/// Get a specific feature flag
///
/// # Arguments
///
/// * `flag_name` - Name of the feature flag
///
/// # Returns
///
/// The feature flag if it exists, None otherwise
pub fn get_flag(&self, flag_name: &str) -> Option<FeatureFlag> {
let flags = self.flags.read().unwrap();
flags.get(flag_name).cloned()
}
/// Load feature flags from a HashMap
///
/// # Arguments
///
/// * `config` - HashMap of flag names to enabled status
pub fn load_from_map(&self, config: HashMap<String, bool>) {
let mut flags = self.flags.write().unwrap();
for (name, enabled) in config {
flags.insert(
name.clone(),
FeatureFlag {
name,
enabled,
description: None,
rollout_percentage: None,
},
);
}
}
/// Load feature flags from detailed configuration
///
/// # Arguments
///
/// * `config` - Vector of FeatureFlag configurations
pub fn load_from_config(&self, config: Vec<FeatureFlag>) {
let mut flags = self.flags.write().unwrap();
for flag in config {
flags.insert(flag.name.clone(), flag);
}
}
/// Export all flags to a HashMap
///
/// # Returns
///
/// HashMap of flag names to enabled status
pub fn export_to_map(&self) -> HashMap<String, bool> {
let flags = self.flags.read().unwrap();
flags.iter().map(|(name, flag)| (name.clone(), flag.enabled)).collect()
}
/// Clear all feature flags
pub fn clear(&self) {
let mut flags = self.flags.write().unwrap();
flags.clear();
}
/// Get the number of feature flags
///
/// # Returns
///
/// The number of flags currently registered
pub fn count(&self) -> usize {
let flags = self.flags.read().unwrap();
flags.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_flag() {
let manager = FeatureFlagManager::new();
// Initially disabled
assert!(!manager.is_enabled("new_feature"));
// Enable flag
manager.set_flag("new_feature", true);
assert!(manager.is_enabled("new_feature"));
// Disable flag
manager.set_flag("new_feature", false);
assert!(!manager.is_enabled("new_feature"));
}
#[test]
fn test_flag_with_options() {
let manager = FeatureFlagManager::new();
manager.set_flag_with_options(
"beta_feature",
true,
Some("Beta testing feature".to_string()),
Some(0.5),
);
assert!(manager.is_enabled("beta_feature"));
let flag = manager.get_flag("beta_feature").unwrap();
assert_eq!(flag.description, Some("Beta testing feature".to_string()));
assert_eq!(flag.rollout_percentage, Some(0.5));
}
#[test]
fn test_load_from_map() {
let manager = FeatureFlagManager::new();
let mut config = HashMap::new();
config.insert("flag1".to_string(), true);
config.insert("flag2".to_string(), false);
manager.load_from_map(config);
assert!(manager.is_enabled("flag1"));
assert!(!manager.is_enabled("flag2"));
assert_eq!(manager.count(), 2);
}
#[test]
fn test_get_all_flags() {
let manager = FeatureFlagManager::new();
manager.set_flag("flag1", true);
manager.set_flag("flag2", false);
let all_flags = manager.get_all_flags();
assert_eq!(all_flags.len(), 2);
}
#[test]
fn test_remove_flag() {
let manager = FeatureFlagManager::new();
manager.set_flag("temp_flag", true);
assert!(manager.is_enabled("temp_flag"));
manager.remove_flag("temp_flag");
assert!(!manager.is_enabled("temp_flag"));
assert_eq!(manager.count(), 0);
}
#[test]
fn test_export_to_map() {
let manager = FeatureFlagManager::new();
manager.set_flag("flag1", true);
manager.set_flag("flag2", false);
let exported = manager.export_to_map();
assert_eq!(exported.get("flag1"), Some(&true));
assert_eq!(exported.get("flag2"), Some(&false));
}
#[test]
fn test_clear() {
let manager = FeatureFlagManager::new();
manager.set_flag("flag1", true);
manager.set_flag("flag2", true);
assert_eq!(manager.count(), 2);
manager.clear();
assert_eq!(manager.count(), 0);
}
#[test]
fn test_thread_safety() {
use std::thread;
let manager = FeatureFlagManager::new();
let manager_clone = manager.clone();
// Spawn thread to set flag
let handle = thread::spawn(move || {
manager_clone.set_flag("concurrent_flag", true);
});
handle.join().unwrap();
// Check flag is set
assert!(manager.is_enabled("concurrent_flag"));
}
}

View file

@ -0,0 +1,211 @@
//! Git utilities for repository discovery and validation
//!
//! Provides utilities for working with Git repositories:
//! - Repository root discovery from any subdirectory
//! - Repository validation
//! - Branch detection
use crate::error::{Error, Result};
use std::path::{Path, PathBuf};
/// Find the Git repository root from the current directory or any subdirectory
///
/// This function uses Git's discovery mechanism to walk up the directory tree
/// until it finds a `.git` directory.
///
/// # Examples
///
/// ```no_run
/// use miyabi_core::git::find_git_root;
///
/// let root = find_git_root(None)?;
/// println!("Git root: {:?}", root);
/// # Ok::<(), miyabi_core::error::Error>(())
/// ```
pub fn find_git_root(start_path: Option<&Path>) -> Result<PathBuf> {
let search_path = match start_path {
Some(p) => p.to_path_buf(),
None => std::env::current_dir()?,
};
match git2::Repository::discover(&search_path) {
Ok(repo) => repo
.workdir()
.map(|p| p.to_path_buf())
.ok_or_else(|| {
Error::Git(
"Repository is bare (no working directory). Miyabi requires a non-bare repository.".to_string()
)
}),
Err(e) => Err(Error::Git(format!(
"Not in a Git repository. Please run miyabi from within a Git repository.\n\
Searched from: {:?}\n\
Git error: {}\n\n\
To initialize a new repository, run: git init",
search_path, e
))),
}
}
/// Validate that a path is a valid Git repository
///
/// # Arguments
/// * `path` - Path to validate
///
/// # Returns
/// `true` if the path is a valid Git repository with a working directory
pub fn is_valid_repository(path: impl AsRef<Path>) -> bool {
match git2::Repository::open(path.as_ref()) {
Ok(repo) => repo.workdir().is_some(),
Err(_) => false,
}
}
/// Check if a path is within a Git repository
///
/// # Arguments
/// * `path` - Path to check
///
/// # Returns
/// `true` if the path is within a Git repository
pub fn is_in_git_repo(path: impl AsRef<Path>) -> bool {
git2::Repository::discover(path.as_ref()).is_ok()
}
/// Get the current branch name of a repository
///
/// # Arguments
/// * `repo_path` - Path to the repository
///
/// # Returns
/// The name of the currently checked out branch
///
/// # Errors
/// Returns an error if the repository is in a detached HEAD state or cannot be opened
pub fn get_current_branch(repo_path: impl AsRef<Path>) -> Result<String> {
let repo = git2::Repository::open(repo_path.as_ref())
.map_err(|e| Error::Git(format!("Failed to open repository: {}", e)))?;
let head = repo
.head()
.map_err(|e| Error::Git(format!("Failed to get HEAD: {}", e)))?;
if !head.is_branch() {
return Err(Error::Git(
"Repository is in detached HEAD state. Please checkout a branch.".to_string(),
));
}
head.shorthand()
.map(|s| s.to_string())
.ok_or_else(|| Error::Git("Failed to get branch name".to_string()))
}
/// Get the main branch name (tries 'main' then 'master')
///
/// # Arguments
/// * `repo_path` - Path to the repository
///
/// # Returns
/// The name of the main branch ('main' or 'master')
///
/// # Errors
/// Returns an error if neither 'main' nor 'master' branch exists
pub fn get_main_branch(repo_path: impl AsRef<Path>) -> Result<String> {
let repo = git2::Repository::open(repo_path.as_ref())
.map_err(|e| Error::Git(format!("Failed to open repository: {}", e)))?;
if repo.find_branch("main", git2::BranchType::Local).is_ok() {
Ok("main".to_string())
} else if repo.find_branch("master", git2::BranchType::Local).is_ok() {
Ok("master".to_string())
} else {
Err(Error::Git(
"Neither 'main' nor 'master' branch found. Please create a main branch.".to_string(),
))
}
}
/// Check if the repository has uncommitted changes
///
/// # Arguments
/// * `repo_path` - Path to the repository
///
/// # Returns
/// `true` if there are uncommitted changes in the working directory or index
pub fn has_uncommitted_changes(repo_path: impl AsRef<Path>) -> Result<bool> {
let repo = git2::Repository::open(repo_path.as_ref())
.map_err(|e| Error::Git(format!("Failed to open repository: {}", e)))?;
let statuses = repo
.statuses(None)
.map_err(|e| Error::Git(format!("Failed to get repository status: {}", e)))?;
Ok(!statuses.is_empty())
}
/// Get the short hash of HEAD
pub fn get_head_short_hash(repo_path: impl AsRef<Path>) -> Result<String> {
let repo = git2::Repository::open(repo_path.as_ref())
.map_err(|e| Error::Git(format!("Failed to open repository: {}", e)))?;
let head = repo
.head()
.map_err(|e| Error::Git(format!("Failed to get HEAD: {}", e)))?;
let commit = head
.peel_to_commit()
.map_err(|e| Error::Git(format!("Failed to get commit: {}", e)))?;
Ok(commit.id().to_string()[..7].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn setup_test_repo() -> (TempDir, PathBuf) {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
git2::Repository::init(&repo_path).unwrap();
(temp_dir, repo_path)
}
#[test]
fn test_find_git_root_from_root() {
let (_temp, repo_path) = setup_test_repo();
let found_root = find_git_root(Some(&repo_path)).unwrap();
let canonical_found = fs::canonicalize(&found_root).unwrap();
let canonical_expected = fs::canonicalize(&repo_path).unwrap();
assert_eq!(canonical_found, canonical_expected);
}
#[test]
fn test_find_git_root_from_subdirectory() {
let (_temp, repo_path) = setup_test_repo();
let sub_dir = repo_path.join("src").join("nested");
fs::create_dir_all(&sub_dir).unwrap();
let found_root = find_git_root(Some(&sub_dir)).unwrap();
let canonical_found = fs::canonicalize(&found_root).unwrap();
let canonical_expected = fs::canonicalize(&repo_path).unwrap();
assert_eq!(canonical_found, canonical_expected);
}
#[test]
fn test_is_valid_repository() {
let (_temp, repo_path) = setup_test_repo();
assert!(is_valid_repository(&repo_path));
let temp_dir = TempDir::new().unwrap();
assert!(!is_valid_repository(temp_dir.path()));
}
#[test]
fn test_is_in_git_repo() {
let (_temp, repo_path) = setup_test_repo();
assert!(is_in_git_repo(&repo_path));
let temp_dir = TempDir::new().unwrap();
assert!(!is_in_git_repo(temp_dir.path()));
}
}

View file

@ -0,0 +1,502 @@
//! Hooks System for Event-Driven Execution
//!
//! Provides a system for registering and executing hooks based on events.
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
/// Hook event types
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookEvent {
/// Before sending a message
PreMessage,
/// After receiving a response
PostMessage,
/// Before executing a tool
PreTool,
/// After tool execution
PostTool,
/// Before committing changes
PreCommit,
/// After committing changes
PostCommit,
/// On session start
SessionStart,
/// On session end
SessionEnd,
/// On error
OnError,
/// Custom event
Custom(String),
}
impl std::fmt::Display for HookEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HookEvent::PreMessage => write!(f, "pre_message"),
HookEvent::PostMessage => write!(f, "post_message"),
HookEvent::PreTool => write!(f, "pre_tool"),
HookEvent::PostTool => write!(f, "post_tool"),
HookEvent::PreCommit => write!(f, "pre_commit"),
HookEvent::PostCommit => write!(f, "post_commit"),
HookEvent::SessionStart => write!(f, "session_start"),
HookEvent::SessionEnd => write!(f, "session_end"),
HookEvent::OnError => write!(f, "on_error"),
HookEvent::Custom(name) => write!(f, "custom:{}", name),
}
}
}
/// Hook action types
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum HookAction {
/// Run a shell command
Shell { command: String },
/// Run an agent with a prompt
Agent {
prompt: String,
#[serde(default)]
spec: Option<String>,
},
/// Send a notification
Notify { title: String, message: String },
/// Log a message
Log { level: String, message: String },
/// Execute a script file
Script { path: PathBuf },
}
/// A single hook definition
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hook {
/// Hook name for identification
pub name: String,
/// Event that triggers this hook
pub event: HookEvent,
/// Action to perform
pub action: HookAction,
/// Whether the hook is enabled
#[serde(default = "default_true")]
pub enabled: bool,
/// Optional condition (tool name, etc.)
#[serde(default)]
pub condition: Option<String>,
/// Timeout in seconds
#[serde(default = "default_timeout")]
pub timeout: u64,
}
fn default_true() -> bool {
true
}
fn default_timeout() -> u64 {
30
}
/// Hook execution context
#[derive(Debug, Clone, Default)]
pub struct HookContext {
/// Event-specific data
pub data: HashMap<String, String>,
/// Tool name (for tool events)
pub tool_name: Option<String>,
/// Tool result (for post_tool)
pub tool_result: Option<String>,
/// Error message (for on_error)
pub error: Option<String>,
}
impl HookContext {
pub fn new() -> Self {
Self::default()
}
pub fn with_tool(mut self, name: &str) -> Self {
self.tool_name = Some(name.to_string());
self
}
pub fn with_result(mut self, result: &str) -> Self {
self.tool_result = Some(result.to_string());
self
}
pub fn with_error(mut self, error: &str) -> Self {
self.error = Some(error.to_string());
self
}
pub fn with_data(mut self, key: &str, value: &str) -> Self {
self.data.insert(key.to_string(), value.to_string());
self
}
}
/// Hook execution result
#[derive(Debug)]
pub struct HookResult {
pub hook_name: String,
pub success: bool,
pub output: Option<String>,
pub error: Option<String>,
}
/// Hooks configuration
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HooksConfig {
/// List of hooks
#[serde(default)]
pub hooks: Vec<Hook>,
}
impl HooksConfig {
/// Load hooks from a YAML file
pub fn load(path: &PathBuf) -> Result<Self> {
let content = std::fs::read_to_string(path)?;
let config: HooksConfig = serde_yaml::from_str(&content)?;
Ok(config)
}
/// Load hooks from default location (~/.miyabi/hooks.yml)
pub fn load_default() -> Result<Self> {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
let path = home.join(".miyabi").join("hooks.yml");
if path.exists() {
Self::load(&path)
} else {
Ok(Self::default())
}
}
/// Save hooks to a file
pub fn save(&self, path: &PathBuf) -> Result<()> {
let content = serde_yaml::to_string(self)?;
std::fs::write(path, content)?;
Ok(())
}
}
/// Hook manager for registering and executing hooks
pub struct HookManager {
hooks: Vec<Hook>,
}
impl Default for HookManager {
fn default() -> Self {
Self::new()
}
}
impl HookManager {
/// Create a new hook manager
pub fn new() -> Self {
Self { hooks: Vec::new() }
}
/// Create from config
pub fn from_config(config: HooksConfig) -> Self {
Self {
hooks: config.hooks,
}
}
/// Load hooks from default location
pub fn load_default() -> Result<Self> {
let config = HooksConfig::load_default()?;
Ok(Self::from_config(config))
}
/// Register a hook
pub fn register(&mut self, hook: Hook) {
self.hooks.push(hook);
}
/// Get hooks for an event
pub fn get_hooks(&self, event: &HookEvent) -> Vec<&Hook> {
self.hooks
.iter()
.filter(|h| h.enabled && &h.event == event)
.collect()
}
/// Execute hooks for an event
pub async fn execute(&self, event: &HookEvent, context: &HookContext) -> Vec<HookResult> {
let hooks = self.get_hooks(event);
let mut results = Vec::new();
for hook in hooks {
// Check condition
if let Some(condition) = &hook.condition {
if let Some(tool_name) = &context.tool_name {
if condition != tool_name {
continue;
}
}
}
let result = self.execute_hook(hook, context).await;
results.push(result);
}
results
}
/// Execute a single hook
async fn execute_hook(&self, hook: &Hook, context: &HookContext) -> HookResult {
match &hook.action {
HookAction::Shell { command } => {
let expanded = self.expand_variables(command, context);
match self.run_shell_command(&expanded, hook.timeout).await {
Ok(output) => HookResult {
hook_name: hook.name.clone(),
success: true,
output: Some(output),
error: None,
},
Err(e) => HookResult {
hook_name: hook.name.clone(),
success: false,
output: None,
error: Some(e.to_string()),
},
}
}
HookAction::Notify { title, message } => {
let expanded_title = self.expand_variables(title, context);
let expanded_message = self.expand_variables(message, context);
// For now, just log the notification
tracing::info!("Hook notification: {} - {}", expanded_title, expanded_message);
HookResult {
hook_name: hook.name.clone(),
success: true,
output: Some(format!("{}: {}", expanded_title, expanded_message)),
error: None,
}
}
HookAction::Log { level, message } => {
let expanded = self.expand_variables(message, context);
match level.as_str() {
"error" => tracing::error!("Hook: {}", expanded),
"warn" => tracing::warn!("Hook: {}", expanded),
"info" => tracing::info!("Hook: {}", expanded),
"debug" => tracing::debug!("Hook: {}", expanded),
_ => tracing::info!("Hook: {}", expanded),
}
HookResult {
hook_name: hook.name.clone(),
success: true,
output: Some(expanded),
error: None,
}
}
HookAction::Script { path } => {
if !path.exists() {
return HookResult {
hook_name: hook.name.clone(),
success: false,
output: None,
error: Some(format!("Script not found: {:?}", path)),
};
}
let command = path.to_string_lossy().to_string();
match self.run_shell_command(&command, hook.timeout).await {
Ok(output) => HookResult {
hook_name: hook.name.clone(),
success: true,
output: Some(output),
error: None,
},
Err(e) => HookResult {
hook_name: hook.name.clone(),
success: false,
output: None,
error: Some(e.to_string()),
},
}
}
HookAction::Agent { prompt, spec: _ } => {
// Agent execution would require the full agent infrastructure
// For now, return a placeholder
let expanded = self.expand_variables(prompt, context);
HookResult {
hook_name: hook.name.clone(),
success: true,
output: Some(format!("Agent prompt: {}", expanded)),
error: None,
}
}
}
}
/// Expand variables in a string
fn expand_variables(&self, input: &str, context: &HookContext) -> String {
let mut result = input.to_string();
// Replace context variables
if let Some(tool_name) = &context.tool_name {
result = result.replace("${tool_name}", tool_name);
}
if let Some(tool_result) = &context.tool_result {
result = result.replace("${tool_result}", tool_result);
}
if let Some(error) = &context.error {
result = result.replace("${error}", error);
}
// Replace custom data
for (key, value) in &context.data {
result = result.replace(&format!("${{{}}}", key), value);
}
result
}
/// Run a shell command
async fn run_shell_command(&self, command: &str, timeout_secs: u64) -> Result<String> {
use std::process::Stdio;
use tokio::process::Command;
use tokio::time::{timeout, Duration};
let output = timeout(
Duration::from_secs(timeout_secs),
Command::new("sh")
.arg("-c")
.arg(command)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output(),
)
.await??;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(anyhow::anyhow!("Command failed: {}", stderr))
}
}
/// Get all registered hooks
pub fn list_hooks(&self) -> &[Hook] {
&self.hooks
}
/// Enable a hook by name
pub fn enable(&mut self, name: &str) {
for hook in &mut self.hooks {
if hook.name == name {
hook.enabled = true;
}
}
}
/// Disable a hook by name
pub fn disable(&mut self, name: &str) {
for hook in &mut self.hooks {
if hook.name == name {
hook.enabled = false;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hook_event_display() {
assert_eq!(HookEvent::PreMessage.to_string(), "pre_message");
assert_eq!(HookEvent::PostTool.to_string(), "post_tool");
assert_eq!(
HookEvent::Custom("test".to_string()).to_string(),
"custom:test"
);
}
#[test]
fn test_hook_context() {
let context = HookContext::new()
.with_tool("bash")
.with_result("success")
.with_data("key", "value");
assert_eq!(context.tool_name, Some("bash".to_string()));
assert_eq!(context.tool_result, Some("success".to_string()));
assert_eq!(context.data.get("key"), Some(&"value".to_string()));
}
#[test]
fn test_expand_variables() {
let manager = HookManager::new();
let context = HookContext::new()
.with_tool("bash")
.with_result("ok")
.with_data("name", "test");
let input = "Tool: ${tool_name}, Result: ${tool_result}, Name: ${name}";
let expanded = manager.expand_variables(input, &context);
assert_eq!(expanded, "Tool: bash, Result: ok, Name: test");
}
#[test]
fn test_hook_config_serialization() {
let config = HooksConfig {
hooks: vec![Hook {
name: "test".to_string(),
event: HookEvent::PostTool,
action: HookAction::Log {
level: "info".to_string(),
message: "Tool completed".to_string(),
},
enabled: true,
condition: Some("bash".to_string()),
timeout: 30,
}],
};
let yaml = serde_yaml::to_string(&config).unwrap();
assert!(yaml.contains("post_tool"));
assert!(yaml.contains("bash"));
}
#[test]
fn test_hook_manager_get_hooks() {
let mut manager = HookManager::new();
manager.register(Hook {
name: "hook1".to_string(),
event: HookEvent::PreTool,
action: HookAction::Log {
level: "info".to_string(),
message: "test".to_string(),
},
enabled: true,
condition: None,
timeout: 30,
});
manager.register(Hook {
name: "hook2".to_string(),
event: HookEvent::PostTool,
action: HookAction::Log {
level: "info".to_string(),
message: "test".to_string(),
},
enabled: true,
condition: None,
timeout: 30,
});
let pre_hooks = manager.get_hooks(&HookEvent::PreTool);
assert_eq!(pre_hooks.len(), 1);
assert_eq!(pre_hooks[0].name, "hook1");
let post_hooks = manager.get_hooks(&HookEvent::PostTool);
assert_eq!(post_hooks.len(), 1);
assert_eq!(post_hooks[0].name, "hook2");
}
}

View file

@ -13,15 +13,18 @@ pub mod feature_flags;
pub mod git;
pub mod github;
pub mod github_tools;
pub mod hooks;
pub mod logger;
pub mod plugin;
pub mod retry;
pub mod rules;
pub mod mcp;
pub mod session;
pub mod token;
pub mod tool;
pub mod tools;
pub mod types;
pub mod workflow;
pub use agent::{
Agent, AgentConfig, AgentError, AgentEvent, AgentResult, ExecutorRegistry, RiskLevel,
@ -64,6 +67,7 @@ pub use github_tools::{
create_github_tool_registry, AddCommentTool, AddLabelsTool, CreateIssueTool,
CreatePullRequestTool, GetIssueTool, ListIssuesTool, ListPullRequestsTool,
};
pub use hooks::{Hook, HookAction, HookContext, HookEvent, HookManager, HookResult, HooksConfig};
pub use logger::{init_logger, init_logger_with_config, LogFormat, LogLevel, LoggerConfig};
pub use plugin::{Plugin, PluginContext, PluginManager, PluginMetadata, PluginResult, PluginState};
pub use retry::{retry_with_backoff, RetryConfig as BackoffRetryConfig};
@ -76,3 +80,10 @@ pub use tools::{
GrepTool, ReadTool, WriteTool,
};
pub use types::*;
pub use workflow::{
FailurePolicy, StepCondition, StepResult, StepStatus, Workflow, WorkflowContext,
WorkflowManager, WorkflowResult, WorkflowStatus, WorkflowStep,
};
pub use mcp::{
McpConfig, McpError, McpManager, McpRequest, McpResponse, McpServer, McpServerConfig, McpTool,
};

View file

@ -0,0 +1,158 @@
//! Logging utilities for Miyabi
//!
//! Provides structured logging with multiple output formats
use tracing_subscriber::{fmt, layer::SubscriberExt, prelude::*, EnvFilter};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogLevel {
Trace,
Debug,
Info,
Warn,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogFormat {
/// Human-readable colored output
Pretty,
/// Compact format for CI/CD
Compact,
/// JSON format for structured parsing
Json,
}
/// Logger configuration
#[derive(Debug, Clone)]
pub struct LoggerConfig {
/// Log level
pub level: LogLevel,
/// Log format
pub format: LogFormat,
}
impl Default for LoggerConfig {
fn default() -> Self {
Self {
level: LogLevel::Info,
format: LogFormat::Pretty,
}
}
}
impl From<&str> for LogLevel {
fn from(s: &str) -> Self {
match s.to_lowercase().as_str() {
"trace" => LogLevel::Trace,
"debug" => LogLevel::Debug,
"info" => LogLevel::Info,
"warn" => LogLevel::Warn,
"error" => LogLevel::Error,
_ => LogLevel::Info,
}
}
}
impl From<LogLevel> for &'static str {
fn from(level: LogLevel) -> Self {
match level {
LogLevel::Trace => "trace",
LogLevel::Debug => "debug",
LogLevel::Info => "info",
LogLevel::Warn => "warn",
LogLevel::Error => "error",
}
}
}
impl From<&str> for LogFormat {
fn from(s: &str) -> Self {
match s.to_lowercase().as_str() {
"json" => LogFormat::Json,
"compact" => LogFormat::Compact,
_ => LogFormat::Pretty,
}
}
}
/// Initialize logging with default configuration
pub fn init_logger(level: LogLevel) {
let config = LoggerConfig {
level,
..Default::default()
};
init_logger_with_config(config);
}
/// Initialize logging with custom configuration
pub fn init_logger_with_config(config: LoggerConfig) {
let level_str: &'static str = config.level.into();
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level_str));
let subscriber = tracing_subscriber::registry().with(filter);
match config.format {
LogFormat::Pretty => {
let fmt_layer = fmt::layer()
.pretty()
.with_target(true)
.with_thread_ids(false)
.with_line_number(true);
subscriber.with(fmt_layer).init();
}
LogFormat::Compact => {
let fmt_layer = fmt::layer()
.compact()
.with_target(false)
.with_thread_ids(false);
subscriber.with(fmt_layer).init();
}
LogFormat::Json => {
let fmt_layer = fmt::layer()
.json()
.with_current_span(true)
.with_span_list(true);
subscriber.with(fmt_layer).init();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_log_level_from_str() {
assert_eq!(LogLevel::from("trace"), LogLevel::Trace);
assert_eq!(LogLevel::from("debug"), LogLevel::Debug);
assert_eq!(LogLevel::from("info"), LogLevel::Info);
assert_eq!(LogLevel::from("warn"), LogLevel::Warn);
assert_eq!(LogLevel::from("error"), LogLevel::Error);
assert_eq!(LogLevel::from("invalid"), LogLevel::Info);
}
#[test]
fn test_log_level_to_str() {
let level_str: &'static str = LogLevel::Trace.into();
assert_eq!(level_str, "trace");
let level_str: &'static str = LogLevel::Info.into();
assert_eq!(level_str, "info");
}
#[test]
fn test_log_format_from_str() {
assert_eq!(LogFormat::from("json"), LogFormat::Json);
assert_eq!(LogFormat::from("compact"), LogFormat::Compact);
assert_eq!(LogFormat::from("pretty"), LogFormat::Pretty);
assert_eq!(LogFormat::from("invalid"), LogFormat::Pretty);
}
#[test]
fn test_logger_config_default() {
let config = LoggerConfig::default();
assert_eq!(config.level, LogLevel::Info);
assert_eq!(config.format, LogFormat::Pretty);
}
}

View file

@ -0,0 +1,407 @@
//! MCP (Model Context Protocol) Support
//!
//! Integration with external MCP tool servers.
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, Command};
/// MCP server configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerConfig {
/// Server name
pub name: String,
/// Command to start the server
pub command: String,
/// Command arguments
#[serde(default)]
pub args: Vec<String>,
/// Environment variables
#[serde(default)]
pub env: HashMap<String, String>,
/// Whether to auto-start
#[serde(default = "default_true")]
pub auto_start: bool,
}
fn default_true() -> bool {
true
}
/// MCP servers configuration
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct McpConfig {
/// List of server configurations
#[serde(default)]
pub servers: Vec<McpServerConfig>,
}
impl McpConfig {
/// Load configuration from a YAML file
pub fn load(path: &PathBuf) -> Result<Self> {
let content = std::fs::read_to_string(path)?;
let config: McpConfig = serde_yaml::from_str(&content)?;
Ok(config)
}
/// Load from default location (~/.miyabi/mcp.yml)
pub fn load_default() -> Result<Self> {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
let path = home.join(".miyabi").join("mcp.yml");
if path.exists() {
Self::load(&path)
} else {
Ok(Self::default())
}
}
/// Save configuration to a file
pub fn save(&self, path: &PathBuf) -> Result<()> {
let content = serde_yaml::to_string(self)?;
std::fs::write(path, content)?;
Ok(())
}
}
/// MCP JSON-RPC request
#[derive(Debug, Clone, Serialize)]
pub struct McpRequest {
pub jsonrpc: String,
pub id: u64,
pub method: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub params: Option<serde_json::Value>,
}
/// MCP JSON-RPC response
#[derive(Debug, Clone, Deserialize)]
pub struct McpResponse {
pub jsonrpc: String,
pub id: u64,
#[serde(default)]
pub result: Option<serde_json::Value>,
#[serde(default)]
pub error: Option<McpError>,
}
/// MCP error
#[derive(Debug, Clone, Deserialize)]
pub struct McpError {
pub code: i32,
pub message: String,
#[serde(default)]
pub data: Option<serde_json::Value>,
}
/// MCP tool definition
#[derive(Debug, Clone, Deserialize)]
pub struct McpTool {
pub name: String,
pub description: String,
#[serde(default)]
pub input_schema: Option<serde_json::Value>,
}
/// MCP server connection
pub struct McpServer {
pub name: String,
process: Child,
request_id: u64,
}
impl McpServer {
/// Start a new MCP server
pub async fn start(config: &McpServerConfig) -> Result<Self> {
let mut cmd = Command::new(&config.command);
cmd.args(&config.args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (key, value) in &config.env {
cmd.env(key, value);
}
let process = cmd.spawn()?;
Ok(Self {
name: config.name.clone(),
process,
request_id: 0,
})
}
/// Send a request and get a response
pub async fn request(&mut self, method: &str, params: Option<serde_json::Value>) -> Result<McpResponse> {
self.request_id += 1;
let request = McpRequest {
jsonrpc: "2.0".to_string(),
id: self.request_id,
method: method.to_string(),
params,
};
let stdin = self.process.stdin.as_mut()
.ok_or_else(|| anyhow::anyhow!("Failed to get stdin"))?;
let stdout = self.process.stdout.as_mut()
.ok_or_else(|| anyhow::anyhow!("Failed to get stdout"))?;
// Send request
let request_json = serde_json::to_string(&request)?;
stdin.write_all(request_json.as_bytes()).await?;
stdin.write_all(b"\n").await?;
stdin.flush().await?;
// Read response
let mut reader = BufReader::new(stdout);
let mut line = String::new();
reader.read_line(&mut line).await?;
let response: McpResponse = serde_json::from_str(&line)?;
Ok(response)
}
/// Initialize the server
pub async fn initialize(&mut self) -> Result<serde_json::Value> {
let params = serde_json::json!({
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {}
},
"clientInfo": {
"name": "miyabi",
"version": env!("CARGO_PKG_VERSION")
}
});
let response = self.request("initialize", Some(params)).await?;
if let Some(error) = response.error {
return Err(anyhow::anyhow!("MCP error: {}", error.message));
}
response.result.ok_or_else(|| anyhow::anyhow!("No result in response"))
}
/// List available tools
pub async fn list_tools(&mut self) -> Result<Vec<McpTool>> {
let response = self.request("tools/list", None).await?;
if let Some(error) = response.error {
return Err(anyhow::anyhow!("MCP error: {}", error.message));
}
let result = response.result.ok_or_else(|| anyhow::anyhow!("No result"))?;
let tools: Vec<McpTool> = serde_json::from_value(
result.get("tools").cloned().unwrap_or(serde_json::Value::Array(vec![]))
)?;
Ok(tools)
}
/// Call a tool
pub async fn call_tool(&mut self, name: &str, arguments: serde_json::Value) -> Result<serde_json::Value> {
let params = serde_json::json!({
"name": name,
"arguments": arguments
});
let response = self.request("tools/call", Some(params)).await?;
if let Some(error) = response.error {
return Err(anyhow::anyhow!("MCP error: {}", error.message));
}
response.result.ok_or_else(|| anyhow::anyhow!("No result"))
}
/// Shutdown the server
pub async fn shutdown(&mut self) -> Result<()> {
let _ = self.request("shutdown", None).await;
self.process.kill().await?;
Ok(())
}
}
/// MCP Manager for handling multiple servers
pub struct McpManager {
servers: HashMap<String, McpServer>,
config: McpConfig,
}
impl McpManager {
/// Create a new MCP manager
pub fn new() -> Self {
Self {
servers: HashMap::new(),
config: McpConfig::default(),
}
}
/// Create from configuration
pub fn with_config(config: McpConfig) -> Self {
Self {
servers: HashMap::new(),
config,
}
}
/// Load configuration from default location
pub fn load_default() -> Result<Self> {
let config = McpConfig::load_default()?;
Ok(Self::with_config(config))
}
/// Start all configured servers
pub async fn start_all(&mut self) -> Result<()> {
let names: Vec<_> = self
.config
.servers
.iter()
.filter(|s| s.auto_start)
.map(|s| s.name.clone())
.collect();
for name in names {
self.start_server(&name).await?;
}
Ok(())
}
/// Start a specific server
pub async fn start_server(&mut self, name: &str) -> Result<()> {
let config = self
.config
.servers
.iter()
.find(|s| s.name == name)
.ok_or_else(|| anyhow::anyhow!("Server not found: {}", name))?
.clone();
let mut server = McpServer::start(&config).await?;
server.initialize().await?;
self.servers.insert(name.to_string(), server);
Ok(())
}
/// Stop a specific server
pub async fn stop_server(&mut self, name: &str) -> Result<()> {
if let Some(mut server) = self.servers.remove(name) {
server.shutdown().await?;
}
Ok(())
}
/// Stop all servers
pub async fn stop_all(&mut self) -> Result<()> {
let names: Vec<_> = self.servers.keys().cloned().collect();
for name in names {
self.stop_server(&name).await?;
}
Ok(())
}
/// Get a server by name
pub fn get_server(&mut self, name: &str) -> Option<&mut McpServer> {
self.servers.get_mut(name)
}
/// List all available tools across all servers
pub async fn list_all_tools(&mut self) -> Result<HashMap<String, Vec<McpTool>>> {
let mut all_tools = HashMap::new();
let names: Vec<_> = self.servers.keys().cloned().collect();
for name in names {
if let Some(server) = self.servers.get_mut(&name) {
let tools = server.list_tools().await?;
all_tools.insert(name, tools);
}
}
Ok(all_tools)
}
/// Call a tool on a specific server
pub async fn call_tool(
&mut self,
server_name: &str,
tool_name: &str,
arguments: serde_json::Value,
) -> Result<serde_json::Value> {
let server = self
.get_server(server_name)
.ok_or_else(|| anyhow::anyhow!("Server not found: {}", server_name))?;
server.call_tool(tool_name, arguments).await
}
/// List running servers
pub fn list_servers(&self) -> Vec<&str> {
self.servers.keys().map(|s| s.as_str()).collect()
}
/// Check if a server is running
pub fn is_running(&self, name: &str) -> bool {
self.servers.contains_key(name)
}
}
impl Default for McpManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mcp_config_serialization() {
let config = McpConfig {
servers: vec![McpServerConfig {
name: "filesystem".to_string(),
command: "npx".to_string(),
args: vec!["@anthropic/mcp-server-filesystem".to_string()],
env: HashMap::new(),
auto_start: true,
}],
};
let yaml = serde_yaml::to_string(&config).unwrap();
assert!(yaml.contains("filesystem"));
assert!(yaml.contains("npx"));
}
#[test]
fn test_mcp_request_serialization() {
let request = McpRequest {
jsonrpc: "2.0".to_string(),
id: 1,
method: "tools/list".to_string(),
params: None,
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("tools/list"));
assert!(!json.contains("params"));
}
#[test]
fn test_mcp_manager() {
let config = McpConfig {
servers: vec![McpServerConfig {
name: "test".to_string(),
command: "echo".to_string(),
args: vec![],
env: HashMap::new(),
auto_start: false,
}],
};
let manager = McpManager::with_config(config);
assert!(!manager.is_running("test"));
assert_eq!(manager.list_servers().len(), 0);
}
}

View file

@ -0,0 +1,408 @@
//! Plugin system for Miyabi
//!
//! Provides a flexible plugin architecture for extending Miyabi functionality.
//!
//! # Example
//!
//! ```rust
//! use miyabi_core::plugin::{Plugin, PluginManager, PluginMetadata, PluginContext, PluginResult};
//! use anyhow::Result;
//!
//! struct MyPlugin;
//!
//! impl Plugin for MyPlugin {
//! fn metadata(&self) -> PluginMetadata {
//! PluginMetadata {
//! name: "my-plugin".to_string(),
//! version: "1.0.0".to_string(),
//! description: Some("My custom plugin".to_string()),
//! author: Some("Miyabi Team".to_string()),
//! }
//! }
//!
//! fn init(&mut self) -> Result<()> {
//! println!("Plugin initialized!");
//! Ok(())
//! }
//!
//! fn execute(&self, context: &PluginContext) -> Result<PluginResult> {
//! Ok(PluginResult {
//! success: true,
//! message: Some("Plugin executed successfully".to_string()),
//! data: None,
//! })
//! }
//! }
//! ```
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
/// Plugin metadata information
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PluginMetadata {
/// Plugin name (unique identifier)
pub name: String,
/// Semantic version
pub version: String,
/// Optional description
pub description: Option<String>,
/// Optional author
pub author: Option<String>,
}
/// Plugin execution context
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PluginContext {
/// Plugin parameters
#[serde(default)]
pub params: HashMap<String, serde_json::Value>,
/// Working directory
pub working_dir: Option<String>,
/// Environment variables
#[serde(default)]
pub env: HashMap<String, String>,
}
/// Plugin execution result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginResult {
/// Execution success status
pub success: bool,
/// Optional result message
pub message: Option<String>,
/// Optional result data
pub data: Option<serde_json::Value>,
}
/// Core plugin trait
///
/// All plugins must implement this trait to be registered with the PluginManager.
pub trait Plugin: Send + Sync {
/// Returns plugin metadata
fn metadata(&self) -> PluginMetadata;
/// Initializes the plugin
///
/// Called once when the plugin is registered.
fn init(&mut self) -> Result<()>;
/// Executes the plugin with the given context
fn execute(&self, context: &PluginContext) -> Result<PluginResult>;
/// Cleans up plugin resources
///
/// Called when the plugin is unregistered or the manager is dropped.
fn shutdown(&mut self) -> Result<()> {
Ok(())
}
}
/// Plugin lifecycle state
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginState {
/// Plugin is registered but not initialized
Registered,
/// Plugin is initialized and ready
Initialized,
/// Plugin encountered an error
Error,
/// Plugin is shut down
Shutdown,
}
/// Internal plugin wrapper
struct PluginEntry {
plugin: Box<dyn Plugin>,
state: PluginState,
}
/// Plugin manager
///
/// Manages plugin registration, initialization, and execution.
#[derive(Clone)]
pub struct PluginManager {
plugins: Arc<RwLock<HashMap<String, PluginEntry>>>,
}
impl PluginManager {
/// Creates a new plugin manager
pub fn new() -> Self {
Self {
plugins: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Registers a plugin
///
/// # Errors
///
/// Returns an error if:
/// - A plugin with the same name already exists
/// - Plugin initialization fails
pub fn register(&self, mut plugin: Box<dyn Plugin>) -> Result<()> {
let metadata = plugin.metadata();
let name = metadata.name.clone();
// Check if plugin already exists
{
let plugins = self.plugins.read().unwrap();
if plugins.contains_key(&name) {
return Err(anyhow!("Plugin '{}' is already registered", name));
}
}
// Initialize plugin
plugin.init()?;
// Store plugin
let mut plugins = self.plugins.write().unwrap();
plugins.insert(
name.clone(),
PluginEntry {
plugin,
state: PluginState::Initialized,
},
);
Ok(())
}
/// Unregisters a plugin
///
/// # Errors
///
/// Returns an error if the plugin doesn't exist
pub fn unregister(&self, name: &str) -> Result<()> {
let mut plugins = self.plugins.write().unwrap();
let mut entry =
plugins.remove(name).ok_or_else(|| anyhow!("Plugin '{}' not found", name))?;
entry.plugin.shutdown()?;
entry.state = PluginState::Shutdown;
Ok(())
}
/// Executes a plugin with the given context
///
/// # Errors
///
/// Returns an error if:
/// - The plugin doesn't exist
/// - The plugin is not initialized
/// - Plugin execution fails
pub fn execute(&self, name: &str, context: &PluginContext) -> Result<PluginResult> {
let plugins = self.plugins.read().unwrap();
let entry = plugins.get(name).ok_or_else(|| anyhow!("Plugin '{}' not found", name))?;
if entry.state != PluginState::Initialized {
return Err(anyhow!("Plugin '{}' is not initialized (state: {:?})", name, entry.state));
}
entry.plugin.execute(context)
}
/// Lists all registered plugins
pub fn list_plugins(&self) -> Vec<PluginMetadata> {
let plugins = self.plugins.read().unwrap();
plugins.values().map(|entry| entry.plugin.metadata()).collect()
}
/// Gets plugin metadata
///
/// # Errors
///
/// Returns an error if the plugin doesn't exist
pub fn get_metadata(&self, name: &str) -> Result<PluginMetadata> {
let plugins = self.plugins.read().unwrap();
plugins
.get(name)
.map(|entry| entry.plugin.metadata())
.ok_or_else(|| anyhow!("Plugin '{}' not found", name))
}
/// Gets plugin state
///
/// # Errors
///
/// Returns an error if the plugin doesn't exist
pub fn get_state(&self, name: &str) -> Result<PluginState> {
let plugins = self.plugins.read().unwrap();
plugins
.get(name)
.map(|entry| entry.state)
.ok_or_else(|| anyhow!("Plugin '{}' not found", name))
}
/// Checks if a plugin exists
pub fn has_plugin(&self, name: &str) -> bool {
let plugins = self.plugins.read().unwrap();
plugins.contains_key(name)
}
/// Gets the number of registered plugins
pub fn count(&self) -> usize {
let plugins = self.plugins.read().unwrap();
plugins.len()
}
}
impl Default for PluginManager {
fn default() -> Self {
Self::new()
}
}
impl Drop for PluginManager {
fn drop(&mut self) {
// Shutdown all plugins
if let Ok(mut plugins) = self.plugins.write() {
for (name, entry) in plugins.iter_mut() {
if let Err(e) = entry.plugin.shutdown() {
eprintln!("Error shutting down plugin '{}': {}", name, e);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TestPlugin {
name: String,
initialized: bool,
}
impl TestPlugin {
fn new(name: &str) -> Self {
Self {
name: name.to_string(),
initialized: false,
}
}
}
impl Plugin for TestPlugin {
fn metadata(&self) -> PluginMetadata {
PluginMetadata {
name: self.name.clone(),
version: "1.0.0".to_string(),
description: Some("Test plugin".to_string()),
author: Some("Test Author".to_string()),
}
}
fn init(&mut self) -> Result<()> {
self.initialized = true;
Ok(())
}
fn execute(&self, _context: &PluginContext) -> Result<PluginResult> {
Ok(PluginResult {
success: true,
message: Some(format!("Plugin '{}' executed", self.name)),
data: None,
})
}
}
#[test]
fn test_register_plugin() {
let manager = PluginManager::new();
let plugin = Box::new(TestPlugin::new("test-plugin"));
assert!(manager.register(plugin).is_ok());
assert_eq!(manager.count(), 1);
assert!(manager.has_plugin("test-plugin"));
}
#[test]
fn test_duplicate_registration() {
let manager = PluginManager::new();
let plugin1 = Box::new(TestPlugin::new("test-plugin"));
let plugin2 = Box::new(TestPlugin::new("test-plugin"));
assert!(manager.register(plugin1).is_ok());
assert!(manager.register(plugin2).is_err());
}
#[test]
fn test_execute_plugin() {
let manager = PluginManager::new();
let plugin = Box::new(TestPlugin::new("test-plugin"));
manager.register(plugin).unwrap();
let context = PluginContext::default();
let result = manager.execute("test-plugin", &context).unwrap();
assert!(result.success);
assert!(result.message.is_some());
}
#[test]
fn test_unregister_plugin() {
let manager = PluginManager::new();
let plugin = Box::new(TestPlugin::new("test-plugin"));
manager.register(plugin).unwrap();
assert_eq!(manager.count(), 1);
manager.unregister("test-plugin").unwrap();
assert_eq!(manager.count(), 0);
}
#[test]
fn test_list_plugins() {
let manager = PluginManager::new();
let plugin1 = Box::new(TestPlugin::new("plugin-1"));
let plugin2 = Box::new(TestPlugin::new("plugin-2"));
manager.register(plugin1).unwrap();
manager.register(plugin2).unwrap();
let plugins = manager.list_plugins();
assert_eq!(plugins.len(), 2);
}
#[test]
fn test_get_metadata() {
let manager = PluginManager::new();
let plugin = Box::new(TestPlugin::new("test-plugin"));
manager.register(plugin).unwrap();
let metadata = manager.get_metadata("test-plugin").unwrap();
assert_eq!(metadata.name, "test-plugin");
assert_eq!(metadata.version, "1.0.0");
}
#[test]
fn test_get_state() {
let manager = PluginManager::new();
let plugin = Box::new(TestPlugin::new("test-plugin"));
manager.register(plugin).unwrap();
let state = manager.get_state("test-plugin").unwrap();
assert_eq!(state, PluginState::Initialized);
}
#[test]
fn test_plugin_not_found() {
let manager = PluginManager::new();
let context = PluginContext::default();
assert!(manager.execute("nonexistent", &context).is_err());
assert!(manager.get_metadata("nonexistent").is_err());
assert!(manager.get_state("nonexistent").is_err());
assert!(manager.unregister("nonexistent").is_err());
}
}

View file

@ -0,0 +1,212 @@
//! Retry logic with exponential backoff
//!
//! Provides utilities for retrying transient failures with exponential backoff.
//! Useful for network operations, external API calls, and transient errors.
use crate::error::{Error, Result};
use std::future::Future;
use std::time::Duration;
/// Configuration for retry behavior
#[derive(Debug, Clone)]
pub struct RetryConfig {
/// Maximum number of retry attempts (excluding the first attempt)
pub max_attempts: u32,
/// Initial delay before first retry (milliseconds)
pub initial_delay_ms: u64,
/// Maximum delay between retries (milliseconds)
pub max_delay_ms: u64,
/// Backoff multiplier (typically 2.0 for exponential backoff)
pub backoff_multiplier: f64,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_attempts: 3,
initial_delay_ms: 100,
max_delay_ms: 30000,
backoff_multiplier: 2.0,
}
}
}
impl RetryConfig {
/// Create a new retry configuration
pub fn new(max_attempts: u32, initial_delay_ms: u64, max_delay_ms: u64) -> Self {
Self {
max_attempts,
initial_delay_ms,
max_delay_ms,
backoff_multiplier: 2.0,
}
}
/// Create aggressive retry config (5 attempts, 50ms initial, 10s max)
pub fn aggressive() -> Self {
Self {
max_attempts: 5,
initial_delay_ms: 50,
max_delay_ms: 10000,
backoff_multiplier: 2.0,
}
}
/// Create conservative retry config (2 attempts, 500ms initial, 60s max)
pub fn conservative() -> Self {
Self {
max_attempts: 2,
initial_delay_ms: 500,
max_delay_ms: 60000,
backoff_multiplier: 2.0,
}
}
/// Calculate delay for a given attempt number (0-indexed)
pub fn calculate_delay(&self, attempt: u32) -> Duration {
let delay_ms = (self.initial_delay_ms as f64 * self.backoff_multiplier.powi(attempt as i32))
.min(self.max_delay_ms as f64) as u64;
Duration::from_millis(delay_ms)
}
}
/// Retry a future with exponential backoff
///
/// # Arguments
/// * `config` - Retry configuration
/// * `operation` - Async operation to retry (must be Fn, not FnOnce)
///
/// # Returns
/// * `Ok(T)` - Operation succeeded
/// * `Err(Error)` - All retry attempts failed
///
/// # Example
/// ```ignore
/// let result = retry_with_backoff(
/// RetryConfig::default(),
/// || async { fetch_data().await }
/// ).await?;
/// ```
pub async fn retry_with_backoff<F, Fut, T>(config: RetryConfig, operation: F) -> Result<T>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<T>>,
{
let mut last_error = None;
for attempt in 0..=config.max_attempts {
match operation().await {
Ok(result) => {
return Ok(result);
}
Err(error) => {
// Check if error is retryable
if !error.is_retryable() {
return Err(error);
}
last_error = Some(error);
// Don't sleep after the last attempt
if attempt < config.max_attempts {
let delay = config.calculate_delay(attempt);
tokio::time::sleep(delay).await;
}
}
}
}
// All attempts failed
Err(last_error.unwrap_or_else(|| Error::Other("No error captured".to_string())))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
#[tokio::test]
async fn test_retry_config_default() {
let config = RetryConfig::default();
assert_eq!(config.max_attempts, 3);
assert_eq!(config.initial_delay_ms, 100);
assert_eq!(config.max_delay_ms, 30000);
assert_eq!(config.backoff_multiplier, 2.0);
}
#[tokio::test]
async fn test_calculate_delay_exponential() {
let config = RetryConfig::new(3, 100, 10000);
assert_eq!(config.calculate_delay(0), Duration::from_millis(100));
assert_eq!(config.calculate_delay(1), Duration::from_millis(200));
assert_eq!(config.calculate_delay(2), Duration::from_millis(400));
assert_eq!(config.calculate_delay(3), Duration::from_millis(800));
}
#[tokio::test]
async fn test_retry_succeeds_immediately() {
let attempt_count = Arc::new(AtomicU32::new(0));
let attempt_count_clone = Arc::clone(&attempt_count);
let config = RetryConfig::new(3, 10, 1000);
let result = retry_with_backoff(config, || {
let count = Arc::clone(&attempt_count_clone);
async move {
count.fetch_add(1, Ordering::SeqCst);
Ok::<String, Error>("success".to_string())
}
})
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), "success");
assert_eq!(attempt_count.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_retry_succeeds_after_failures() {
let attempt_count = Arc::new(AtomicU32::new(0));
let attempt_count_clone = Arc::clone(&attempt_count);
let config = RetryConfig::new(3, 10, 1000);
let result = retry_with_backoff(config, || {
let count = Arc::clone(&attempt_count_clone);
async move {
let current = count.fetch_add(1, Ordering::SeqCst);
if current < 2 {
Err(Error::Timeout(1000))
} else {
Ok::<String, Error>("success".to_string())
}
}
})
.await;
assert!(result.is_ok());
assert_eq!(attempt_count.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn test_retry_non_retryable_fails_immediately() {
let attempt_count = Arc::new(AtomicU32::new(0));
let attempt_count_clone = Arc::clone(&attempt_count);
let config = RetryConfig::new(3, 10, 1000);
let result = retry_with_backoff(config, || {
let count = Arc::clone(&attempt_count_clone);
async move {
count.fetch_add(1, Ordering::SeqCst);
Err::<String, Error>(Error::Validation("Invalid".to_string()))
}
})
.await;
assert!(result.is_err());
assert_eq!(attempt_count.load(Ordering::SeqCst), 1);
}
}

View file

@ -0,0 +1,325 @@
//! Project-specific rules support (.miyabirules)
//!
//! This module provides support for loading and applying custom rules from `.miyabirules` files.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;
/// Error types for rules operations
#[derive(Error, Debug)]
pub enum RulesError {
/// File not found
#[error("Rules file not found: {0}")]
FileNotFound(PathBuf),
/// Parse error
#[error("Failed to parse rules file: {0}")]
ParseError(String),
/// Validation error
#[error("Validation error: {0}")]
ValidationError(String),
/// I/O error
#[error("I/O error: {0}")]
IoError(#[from] std::io::Error),
}
pub type Result<T> = std::result::Result<T, RulesError>;
/// A single rule with pattern matching and suggestion
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Rule {
/// Rule name
pub name: String,
/// Pattern to match (regex)
#[serde(default)]
pub pattern: Option<String>,
/// Suggestion message
pub suggestion: String,
/// File extension filters (e.g., ["rs", "toml"])
#[serde(default)]
pub file_extensions: Vec<String>,
/// Severity: "info", "warning", "error"
#[serde(default = "default_severity")]
pub severity: String,
/// Whether this rule is enabled
#[serde(default = "default_enabled")]
pub enabled: bool,
}
fn default_severity() -> String {
"info".to_string()
}
fn default_enabled() -> bool {
true
}
impl Rule {
/// Check if this rule applies to a given file extension
pub fn applies_to_file(&self, file_path: &Path) -> bool {
if self.file_extensions.is_empty() {
return true;
}
if let Some(ext) = file_path.extension() {
let ext_str = ext.to_string_lossy().to_string();
self.file_extensions.iter().any(|e| e == &ext_str)
} else {
false
}
}
/// Check if this rule matches a given line of code
pub fn matches(&self, line: &str) -> bool {
if let Some(pattern) = &self.pattern {
line.contains(pattern)
} else {
false
}
}
}
/// Agent-specific preferences
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct AgentPreferences {
/// Code style preference
#[serde(default)]
pub style: Option<String>,
/// Error handling strategy
#[serde(default)]
pub error_handling: Option<String>,
/// Minimum quality score
#[serde(default)]
pub min_score: Option<u8>,
/// Enable strict Clippy checks
#[serde(default)]
pub clippy_strict: Option<bool>,
/// Custom agent-specific settings
#[serde(flatten)]
pub custom: HashMap<String, serde_json::Value>,
}
/// Root configuration structure for .miyabirules
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MiyabiRules {
/// Version of the rules format
#[serde(default = "default_version")]
pub version: u32,
/// List of rules
#[serde(default)]
pub rules: Vec<Rule>,
/// Agent preferences by agent type
#[serde(default)]
pub agent_preferences: HashMap<String, AgentPreferences>,
/// Global settings
#[serde(default)]
pub settings: HashMap<String, serde_json::Value>,
}
fn default_version() -> u32 {
1
}
impl Default for MiyabiRules {
fn default() -> Self {
Self {
version: 1,
rules: Vec::new(),
agent_preferences: HashMap::new(),
settings: HashMap::new(),
}
}
}
impl MiyabiRules {
/// Create a new empty rules configuration
pub fn new() -> Self {
Self::default()
}
/// Validate the rules configuration
pub fn validate(&self) -> Result<()> {
if self.version != 1 {
return Err(RulesError::ValidationError(format!(
"Unsupported version: {}. Only version 1 is supported.",
self.version
)));
}
for rule in &self.rules {
if rule.name.is_empty() {
return Err(RulesError::ValidationError(
"Rule name cannot be empty".to_string(),
));
}
if rule.suggestion.is_empty() {
return Err(RulesError::ValidationError(format!(
"Rule '{}' must have a suggestion",
rule.name
)));
}
match rule.severity.as_str() {
"info" | "warning" | "error" => {}
_ => {
return Err(RulesError::ValidationError(format!(
"Invalid severity '{}' for rule '{}'. Must be: info, warning, or error",
rule.severity, rule.name
)))
}
}
}
Ok(())
}
/// Get rules that apply to a specific file
pub fn rules_for_file(&self, file_path: &Path) -> Vec<&Rule> {
self.rules
.iter()
.filter(|r| r.enabled && r.applies_to_file(file_path))
.collect()
}
/// Get agent preferences for a specific agent type
pub fn get_agent_preferences(&self, agent_type: &str) -> Option<&AgentPreferences> {
self.agent_preferences.get(agent_type)
}
/// Get a global setting value
pub fn get_setting(&self, key: &str) -> Option<&serde_json::Value> {
self.settings.get(key)
}
}
/// Rules loader for loading .miyabirules files
pub struct RulesLoader {
/// Root directory to search for .miyabirules
root_dir: PathBuf,
}
impl RulesLoader {
/// Create a new RulesLoader
pub fn new(root_dir: PathBuf) -> Self {
Self { root_dir }
}
/// Find .miyabirules file in the directory hierarchy
pub fn find_rules_file(&self) -> Option<PathBuf> {
let mut current = self.root_dir.clone();
loop {
let rules_path = current.join(".miyabirules");
if rules_path.exists() {
return Some(rules_path);
}
let rules_yaml = current.join(".miyabirules.yaml");
if rules_yaml.exists() {
return Some(rules_yaml);
}
let rules_yml = current.join(".miyabirules.yml");
if rules_yml.exists() {
return Some(rules_yml);
}
if !current.pop() {
break;
}
}
None
}
/// Load rules from .miyabirules file
pub fn load(&self) -> Result<Option<MiyabiRules>> {
let rules_path = match self.find_rules_file() {
Some(path) => path,
None => return Ok(None),
};
let content = fs::read_to_string(&rules_path)?;
let rules: MiyabiRules = serde_yaml::from_str(&content).map_err(|e| {
RulesError::ParseError(format!("Failed to parse {}: {}", rules_path.display(), e))
})?;
rules.validate()?;
Ok(Some(rules))
}
/// Load rules or return default if not found
pub fn load_or_default(&self) -> Result<MiyabiRules> {
self.load().map(|opt| opt.unwrap_or_default())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_rule_applies_to_file() {
let rule = Rule {
name: "Rust rule".to_string(),
pattern: None,
suggestion: "Test".to_string(),
file_extensions: vec!["rs".to_string()],
severity: "info".to_string(),
enabled: true,
};
assert!(rule.applies_to_file(Path::new("main.rs")));
assert!(!rule.applies_to_file(Path::new("main.py")));
}
#[test]
fn test_rule_matches() {
let rule = Rule {
name: "Test".to_string(),
pattern: Some("async".to_string()),
suggestion: "Test".to_string(),
file_extensions: vec![],
severity: "info".to_string(),
enabled: true,
};
assert!(rule.matches("async fn test() {}"));
assert!(!rule.matches("fn test() {}"));
}
#[test]
fn test_rules_validation() {
let rules = MiyabiRules::default();
assert!(rules.validate().is_ok());
}
#[test]
fn test_rules_loader_not_found() {
let temp_dir = TempDir::new().unwrap();
let loader = RulesLoader::new(temp_dir.path().to_path_buf());
let rules = loader.load().unwrap();
assert!(rules.is_none());
}
}

View file

@ -0,0 +1,557 @@
//! Multi-Agent Workflow System
//!
//! Orchestrate multiple agents in sequence or parallel execution.
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
/// Workflow definition
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workflow {
/// Workflow name
pub name: String,
/// Description
#[serde(default)]
pub description: String,
/// Workflow steps
pub steps: Vec<WorkflowStep>,
/// Global variables
#[serde(default)]
pub variables: HashMap<String, String>,
/// On failure behavior
#[serde(default)]
pub on_failure: FailurePolicy,
}
/// A single step in the workflow
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowStep {
/// Step ID for reference
pub id: String,
/// Step name
pub name: String,
/// Agent spec to use
#[serde(default)]
pub agent: Option<String>,
/// Task/prompt for the agent
pub task: String,
/// Dependencies (step IDs that must complete first)
#[serde(default)]
pub depends_on: Vec<String>,
/// Condition for execution
#[serde(default)]
pub condition: Option<StepCondition>,
/// Timeout in seconds
#[serde(default = "default_step_timeout")]
pub timeout: u64,
/// Retry configuration
#[serde(default)]
pub retry: RetryConfig,
/// Output variable name to store result
#[serde(default)]
pub output: Option<String>,
}
fn default_step_timeout() -> u64 {
300
}
/// Condition for step execution
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum StepCondition {
/// Always execute
Always,
/// Execute if previous step succeeded
OnSuccess,
/// Execute if previous step failed
OnFailure,
/// Execute if expression is true
If { expression: String },
}
impl Default for StepCondition {
fn default() -> Self {
StepCondition::Always
}
}
/// Retry configuration for a step
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetryConfig {
/// Maximum retry attempts
#[serde(default = "default_max_retries")]
pub max_attempts: u32,
/// Delay between retries in seconds
#[serde(default = "default_retry_delay")]
pub delay: u64,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_attempts: 1,
delay: 5,
}
}
}
fn default_max_retries() -> u32 {
1
}
fn default_retry_delay() -> u64 {
5
}
/// Failure policy for workflow
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FailurePolicy {
/// Stop workflow on first failure
Stop,
/// Continue with remaining steps
Continue,
/// Run cleanup steps
Cleanup,
}
impl Default for FailurePolicy {
fn default() -> Self {
FailurePolicy::Stop
}
}
/// Step execution result
#[derive(Debug, Clone)]
pub struct StepResult {
pub step_id: String,
pub status: StepStatus,
pub output: Option<String>,
pub error: Option<String>,
pub duration_ms: u64,
}
/// Step execution status
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StepStatus {
Pending,
Running,
Completed,
Failed,
Skipped,
}
/// Workflow execution context
#[derive(Debug, Clone, Default)]
pub struct WorkflowContext {
/// Variables available to steps
pub variables: HashMap<String, String>,
/// Results from completed steps
pub results: HashMap<String, StepResult>,
}
impl WorkflowContext {
pub fn new() -> Self {
Self::default()
}
pub fn with_variables(mut self, vars: HashMap<String, String>) -> Self {
self.variables = vars;
self
}
pub fn set_variable(&mut self, key: &str, value: &str) {
self.variables.insert(key.to_string(), value.to_string());
}
pub fn get_variable(&self, key: &str) -> Option<&String> {
self.variables.get(key)
}
pub fn add_result(&mut self, result: StepResult) {
self.results.insert(result.step_id.clone(), result);
}
pub fn get_result(&self, step_id: &str) -> Option<&StepResult> {
self.results.get(step_id)
}
/// Expand variables in a string
pub fn expand(&self, input: &str) -> String {
let mut result = input.to_string();
for (key, value) in &self.variables {
result = result.replace(&format!("${{{}}}", key), value);
}
result
}
}
/// Workflow execution result
#[derive(Debug)]
pub struct WorkflowResult {
pub workflow_name: String,
pub status: WorkflowStatus,
pub steps: Vec<StepResult>,
pub duration_ms: u64,
}
/// Workflow execution status
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkflowStatus {
Completed,
Failed,
PartiallyCompleted,
}
/// Workflow manager for loading and executing workflows
pub struct WorkflowManager {
workflows: HashMap<String, Workflow>,
}
impl Default for WorkflowManager {
fn default() -> Self {
Self::new()
}
}
impl WorkflowManager {
/// Create a new workflow manager
pub fn new() -> Self {
Self {
workflows: HashMap::new(),
}
}
/// Load a workflow from a YAML file
pub fn load_workflow(&mut self, path: &PathBuf) -> Result<String> {
let content = std::fs::read_to_string(path)?;
let workflow: Workflow = serde_yaml::from_str(&content)?;
let name = workflow.name.clone();
self.workflows.insert(name.clone(), workflow);
Ok(name)
}
/// Load all workflows from a directory
pub fn load_from_directory(&mut self, dir: &PathBuf) -> Result<Vec<String>> {
let mut loaded = Vec::new();
if dir.exists() && dir.is_dir() {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().map(|e| e == "yml" || e == "yaml").unwrap_or(false) {
if let Ok(name) = self.load_workflow(&path) {
loaded.push(name);
}
}
}
}
Ok(loaded)
}
/// Register a workflow directly
pub fn register(&mut self, workflow: Workflow) {
self.workflows.insert(workflow.name.clone(), workflow);
}
/// Get a workflow by name
pub fn get_workflow(&self, name: &str) -> Option<&Workflow> {
self.workflows.get(name)
}
/// List all registered workflows
pub fn list_workflows(&self) -> Vec<&str> {
self.workflows.keys().map(|s| s.as_str()).collect()
}
/// Execute a workflow
pub async fn execute(&self, name: &str, initial_vars: HashMap<String, String>) -> Result<WorkflowResult> {
let workflow = self
.workflows
.get(name)
.ok_or_else(|| anyhow::anyhow!("Workflow not found: {}", name))?;
let start_time = std::time::Instant::now();
let mut context = WorkflowContext::new().with_variables(workflow.variables.clone());
// Add initial variables
for (key, value) in initial_vars {
context.set_variable(&key, &value);
}
let mut step_results = Vec::new();
let mut all_succeeded = true;
// Build dependency graph and execute
let execution_order = self.build_execution_order(workflow)?;
for step_id in execution_order {
let step = workflow
.steps
.iter()
.find(|s| s.id == step_id)
.ok_or_else(|| anyhow::anyhow!("Step not found: {}", step_id))?;
// Check dependencies
let deps_satisfied = step.depends_on.iter().all(|dep_id| {
context
.get_result(dep_id)
.map(|r| r.status == StepStatus::Completed)
.unwrap_or(false)
});
if !deps_satisfied {
let result = StepResult {
step_id: step.id.clone(),
status: StepStatus::Skipped,
output: None,
error: Some("Dependencies not satisfied".to_string()),
duration_ms: 0,
};
step_results.push(result.clone());
context.add_result(result);
continue;
}
// Check condition
if !self.evaluate_condition(&step.condition, &context) {
let result = StepResult {
step_id: step.id.clone(),
status: StepStatus::Skipped,
output: None,
error: Some("Condition not met".to_string()),
duration_ms: 0,
};
step_results.push(result.clone());
context.add_result(result);
continue;
}
// Execute step
let step_start = std::time::Instant::now();
let expanded_task = context.expand(&step.task);
// Placeholder for actual agent execution
let result = StepResult {
step_id: step.id.clone(),
status: StepStatus::Completed,
output: Some(format!("Executed: {}", expanded_task)),
error: None,
duration_ms: step_start.elapsed().as_millis() as u64,
};
// Store output variable if specified
if let Some(output_var) = &step.output {
if let Some(output) = &result.output {
context.set_variable(output_var, output);
}
}
if result.status == StepStatus::Failed {
all_succeeded = false;
if matches!(workflow.on_failure, FailurePolicy::Stop) {
step_results.push(result.clone());
context.add_result(result);
break;
}
}
step_results.push(result.clone());
context.add_result(result);
}
let status = if all_succeeded {
WorkflowStatus::Completed
} else if step_results.iter().any(|r| r.status == StepStatus::Completed) {
WorkflowStatus::PartiallyCompleted
} else {
WorkflowStatus::Failed
};
Ok(WorkflowResult {
workflow_name: name.to_string(),
status,
steps: step_results,
duration_ms: start_time.elapsed().as_millis() as u64,
})
}
/// Build execution order from dependencies (topological sort)
fn build_execution_order(&self, workflow: &Workflow) -> Result<Vec<String>> {
let mut order = Vec::new();
let mut visited = HashMap::new();
let mut temp_visited = HashMap::new();
for step in &workflow.steps {
if !visited.contains_key(&step.id) {
self.visit_step(workflow, &step.id, &mut visited, &mut temp_visited, &mut order)?;
}
}
Ok(order)
}
fn visit_step(
&self,
workflow: &Workflow,
step_id: &str,
visited: &mut HashMap<String, bool>,
temp_visited: &mut HashMap<String, bool>,
order: &mut Vec<String>,
) -> Result<()> {
if temp_visited.get(step_id).copied().unwrap_or(false) {
return Err(anyhow::anyhow!("Circular dependency detected at step: {}", step_id));
}
if visited.get(step_id).copied().unwrap_or(false) {
return Ok(());
}
temp_visited.insert(step_id.to_string(), true);
let step = workflow
.steps
.iter()
.find(|s| s.id == step_id)
.ok_or_else(|| anyhow::anyhow!("Step not found: {}", step_id))?;
for dep_id in &step.depends_on {
self.visit_step(workflow, dep_id, visited, temp_visited, order)?;
}
temp_visited.insert(step_id.to_string(), false);
visited.insert(step_id.to_string(), true);
order.push(step_id.to_string());
Ok(())
}
/// Evaluate a step condition
fn evaluate_condition(&self, condition: &Option<StepCondition>, context: &WorkflowContext) -> bool {
match condition {
None | Some(StepCondition::Always) => true,
Some(StepCondition::OnSuccess) => context
.results
.values()
.last()
.map(|r| r.status == StepStatus::Completed)
.unwrap_or(true),
Some(StepCondition::OnFailure) => context
.results
.values()
.last()
.map(|r| r.status == StepStatus::Failed)
.unwrap_or(false),
Some(StepCondition::If { expression }) => {
// Simple expression evaluation (variable == value)
let expanded = context.expand(expression);
!expanded.is_empty() && expanded != "false" && expanded != "0"
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_workflow_context() {
let mut context = WorkflowContext::new();
context.set_variable("name", "test");
context.set_variable("version", "1.0");
assert_eq!(context.get_variable("name"), Some(&"test".to_string()));
assert_eq!(
context.expand("Project: ${name} v${version}"),
"Project: test v1.0"
);
}
#[test]
fn test_workflow_serialization() {
let workflow = Workflow {
name: "test-workflow".to_string(),
description: "Test".to_string(),
steps: vec![
WorkflowStep {
id: "step1".to_string(),
name: "First Step".to_string(),
agent: Some("code-reviewer".to_string()),
task: "Review code".to_string(),
depends_on: vec![],
condition: None,
timeout: 300,
retry: RetryConfig::default(),
output: Some("review_result".to_string()),
},
],
variables: HashMap::new(),
on_failure: FailurePolicy::Stop,
};
let yaml = serde_yaml::to_string(&workflow).unwrap();
assert!(yaml.contains("test-workflow"));
assert!(yaml.contains("step1"));
}
#[test]
fn test_workflow_manager() {
let mut manager = WorkflowManager::new();
let workflow = Workflow {
name: "test".to_string(),
description: "Test workflow".to_string(),
steps: vec![],
variables: HashMap::new(),
on_failure: FailurePolicy::Stop,
};
manager.register(workflow);
assert!(manager.get_workflow("test").is_some());
assert_eq!(manager.list_workflows(), vec!["test"]);
}
#[tokio::test]
async fn test_workflow_execution() {
let mut manager = WorkflowManager::new();
let workflow = Workflow {
name: "simple".to_string(),
description: "Simple workflow".to_string(),
steps: vec![
WorkflowStep {
id: "step1".to_string(),
name: "Step 1".to_string(),
agent: None,
task: "Task 1".to_string(),
depends_on: vec![],
condition: None,
timeout: 30,
retry: RetryConfig::default(),
output: None,
},
WorkflowStep {
id: "step2".to_string(),
name: "Step 2".to_string(),
agent: None,
task: "Task 2 depends on ${result}".to_string(),
depends_on: vec!["step1".to_string()],
condition: None,
timeout: 30,
retry: RetryConfig::default(),
output: None,
},
],
variables: HashMap::new(),
on_failure: FailurePolicy::Stop,
};
manager.register(workflow);
let result = manager.execute("simple", HashMap::new()).await.unwrap();
assert_eq!(result.workflow_name, "simple");
assert_eq!(result.status, WorkflowStatus::Completed);
assert_eq!(result.steps.len(), 2);
}
}

View file

@ -50,4 +50,5 @@ once_cell = { workspace = true }
uuid = { workspace = true }
[features]
default = ["clipboard"]
clipboard = ["arboard"]

View file

@ -15,6 +15,7 @@ use crate::history_cell::{
use crate::views::{MainView, ViewAction};
use miyabi_core::anthropic::{AnthropicClient, ContentBlock, Message, StreamEvent};
use miyabi_core::config::Config;
use miyabi_core::rules::{MiyabiRules, RulesLoader};
use miyabi_core::session::{Session, SessionStorage};
use miyabi_core::tool::ToolRegistry;
use miyabi_core::tools::create_standard_tool_registry;
@ -72,6 +73,8 @@ pub struct App {
max_tokens: u32,
/// Whether to request extended thinking
thinking: bool,
/// Project rules loaded from .miyabirules
rules: Option<MiyabiRules>,
}
impl App {
@ -135,6 +138,27 @@ impl App {
let max_tokens = config.api.max_tokens;
let thinking = config.api.thinking;
// Load project rules from .miyabirules
let cwd = std::env::current_dir().unwrap_or_default();
let loader = RulesLoader::new(cwd);
let rules = match loader.load() {
Ok(Some(rules)) => {
let count = rules.rules.len();
if count > 0 {
view.notifications.info(
"Rules Loaded",
format!("{} project rules active", count),
);
}
Some(rules)
}
Ok(None) => None,
Err(e) => {
view.notifications.error("Rules Error", format!("Failed to load: {}", e));
None
}
};
Self {
should_quit: false,
view,
@ -151,9 +175,15 @@ impl App {
model_name,
max_tokens,
thinking,
rules,
}
}
/// Get the loaded project rules
pub fn get_rules(&self) -> Option<&MiyabiRules> {
self.rules.as_ref()
}
/// Toggle agent mode
pub fn toggle_agent_mode(&mut self) {
self.agent_mode = !self.agent_mode;
@ -241,18 +271,106 @@ impl App {
self.view.notifications.panel.push(notification);
}
ViewAction::Copy(text) => {
// TODO: Implement clipboard support
#[cfg(feature = "clipboard")]
{
match arboard::Clipboard::new() {
Ok(mut clipboard) => {
if let Err(e) = clipboard.set_text(&text) {
self.view
.notifications
.error("Clipboard Error", e.to_string());
} else {
self.view
.notifications
.info("Copied", format!("{} chars", text.len()));
}
}
Err(e) => {
self.view
.notifications
.error("Clipboard Error", e.to_string());
}
}
}
#[cfg(not(feature = "clipboard"))]
{
self.view
.notifications
.warning("Clipboard", "Clipboard feature not enabled");
let _ = text; // Suppress unused warning
}
}
ViewAction::OpenFile(path) => {
// TODO: Implement file opening
self.view.notifications.info("Open File", &path);
// Open file with system default application
#[cfg(target_os = "macos")]
let result = std::process::Command::new("open").arg(&path).spawn();
#[cfg(target_os = "linux")]
let result = std::process::Command::new("xdg-open").arg(&path).spawn();
#[cfg(target_os = "windows")]
let result = std::process::Command::new("cmd")
.args(["/C", "start", "", &path])
.spawn();
match result {
Ok(_) => {
self.view.notifications.info("Opened", &path);
}
Err(e) => {
self.view
.notifications
.error("Open Error", e.to_string());
}
}
}
ViewAction::ResumeSession(session_id) => {
// TODO: Implement session resume
self.view.notifications.info("Resume Session", &session_id);
match self.load_session(&session_id) {
Ok(()) => {
// Rebuild UI from loaded conversation
self.view.history.clear();
let timestamp =
chrono::Local::now().format("%H:%M").to_string();
// Replay conversation into UI
for msg in &self.conversation {
match msg.role {
miyabi_core::anthropic::Role::User => {
if let Some(ContentBlock::Text { text }) =
msg.content.first()
{
self.view.push_message(Box::new(
UserMessageCell {
content: text.clone(),
timestamp: timestamp.clone(),
},
));
}
}
miyabi_core::anthropic::Role::Assistant => {
if let Some(ContentBlock::Text { text }) =
msg.content.first()
{
let mut cell = AssistantMessageCell::new(
timestamp.clone(),
);
cell.set_content(text);
cell.set_complete();
self.view.push_message(Box::new(cell));
}
}
}
}
self.view.notifications.success(
"Session Resumed",
format!("{} messages loaded", self.conversation.len()),
);
}
Err(e) => {
self.view
.notifications
.error("Resume Error", e.to_string());
}
}
}
ViewAction::ToggleAgentMode => {
self.toggle_agent_mode();
@ -815,7 +933,7 @@ impl App {
match client
.message_stream(
self.conversation.clone(),
Some("You are a helpful AI assistant. Be concise and clear.".to_string()),
self.system_prompt.clone(),
None,
None,
)