Skip to main content

swarm_engine_llm/
decider.rs

1//! LLM Decider - Action 選択のための LLM 抽象
2//!
3//! 軽量LLM(Qwen2.5-Coder 1.5B等)による高速な Action 選択
4//!
5//! # 概念
6//!
7//! - [`LlmDecider`]: LLM への問い合わせ抽象(非同期、バッチ対応)
8//!
9//! # 型の統一
10//!
11//! LLM層はCore層の型を直接使用:
12//! - `WorkerDecisionRequest` - リクエスト
13//! - `DecisionResponse` - レスポンス
14
15use std::future::Future;
16use std::pin::Pin;
17
18/// バッチ決定の戻り値型(clippy::type_complexity 対策)
19pub type BatchDecisionFuture<'a> =
20    Pin<Box<dyn Future<Output = Vec<Result<DecisionResponse, LlmError>>> + Send + 'a>>;
21
22// Core の型を再エクスポート
23pub use swarm_engine_core::agent::{
24    ActionCandidate, ActionParam, DecisionResponse, ResolvedContext, WorkerDecisionRequest,
25};
26pub use swarm_engine_core::types::LoraConfig;
27
28/// LLM エラー
29#[derive(Debug, Clone, thiserror::Error)]
30pub enum LlmError {
31    /// 一時的エラー(リトライ可能)
32    #[error("LLM error (transient): {0}")]
33    Transient(String),
34
35    /// 恒久的エラー(リトライ不可)
36    #[error("LLM error: {0}")]
37    Permanent(String),
38}
39
40impl LlmError {
41    pub fn transient(message: impl Into<String>) -> Self {
42        Self::Transient(message.into())
43    }
44
45    pub fn permanent(message: impl Into<String>) -> Self {
46        Self::Permanent(message.into())
47    }
48
49    pub fn is_transient(&self) -> bool {
50        matches!(self, Self::Transient(_))
51    }
52
53    pub fn message(&self) -> &str {
54        match self {
55            Self::Transient(msg) => msg,
56            Self::Permanent(msg) => msg,
57        }
58    }
59}
60
61impl From<swarm_engine_core::error::SwarmError> for LlmError {
62    fn from(err: swarm_engine_core::error::SwarmError) -> Self {
63        if err.is_transient() {
64            Self::Transient(err.message())
65        } else {
66            Self::Permanent(err.message())
67        }
68    }
69}
70
71impl From<LlmError> for swarm_engine_core::error::SwarmError {
72    fn from(err: LlmError) -> Self {
73        match err {
74            LlmError::Transient(message) => {
75                swarm_engine_core::error::SwarmError::LlmTransient { message }
76            }
77            LlmError::Permanent(message) => {
78                swarm_engine_core::error::SwarmError::LlmPermanent { message }
79            }
80        }
81    }
82}
83
84/// LLM Decider trait
85///
86/// Core の `WorkerDecisionRequest` を受け取り、`DecisionResponse` を返す。
87pub trait LlmDecider: Send + Sync {
88    /// 単一の決定
89    fn decide(
90        &self,
91        request: WorkerDecisionRequest,
92    ) -> Pin<Box<dyn Future<Output = Result<DecisionResponse, LlmError>> + Send + '_>>;
93
94    /// 生のプロンプトを送信し、生のレスポンスを取得
95    ///
96    /// DependencyGraph 生成など、Action 選択以外の用途に使用。
97    /// デフォルト実装はエラーを返す(未対応)。
98    ///
99    /// # Arguments
100    /// * `prompt` - 送信するプロンプト
101    /// * `lora` - LoRA 設定(None の場合はベースモデルのみ)
102    fn call_raw(
103        &self,
104        _prompt: &str,
105        _lora: Option<&LoraConfig>,
106    ) -> Pin<Box<dyn Future<Output = Result<String, LlmError>> + Send + '_>> {
107        Box::pin(async { Err(LlmError::permanent("call_raw not implemented")) })
108    }
109
110    /// バッチ決定(100+ Agent 対応)
111    fn decide_batch(&self, requests: Vec<WorkerDecisionRequest>) -> BatchDecisionFuture<'_> {
112        // デフォルト実装: 順次処理
113        Box::pin(async move {
114            let mut results = Vec::with_capacity(requests.len());
115            for req in requests {
116                results.push(self.decide(req).await);
117            }
118            results
119        })
120    }
121
122    /// モデル名
123    fn model_name(&self) -> &str;
124
125    /// エンドポイント
126    fn endpoint(&self) -> &str {
127        "unknown"
128    }
129
130    /// ヘルスチェック
131    fn is_healthy(&self) -> Pin<Box<dyn Future<Output = bool> + Send + '_>>;
132
133    /// 最大同時実行数を取得(サーバーのスロット数等)
134    ///
135    /// デフォルトはNone(無制限)。
136    /// 実装側でサーバーに問い合わせてスロット数を返すことができる。
137    fn max_concurrency(&self) -> Pin<Box<dyn Future<Output = Option<usize>> + Send + '_>> {
138        Box::pin(async { None })
139    }
140}
141
142/// Decider 設定
143#[derive(Debug, Clone)]
144pub struct LlmDeciderConfig {
145    /// モデル名
146    pub model: String,
147    /// エンドポイント
148    pub endpoint: String,
149    /// タイムアウト(ミリ秒)
150    pub timeout_ms: u64,
151    /// 最大バッチサイズ
152    pub max_batch_size: usize,
153    /// Temperature
154    pub temperature: f32,
155    /// カスタムシステムプロンプト(テンプレート変数: {query}, {candidates}, {world_state})
156    pub system_prompt: Option<String>,
157}
158
159impl Default for LlmDeciderConfig {
160    fn default() -> Self {
161        Self {
162            model: "qwen2.5-coder:1.5b".to_string(),
163            endpoint: "http://localhost:11434".to_string(),
164            timeout_ms: 5000,
165            max_batch_size: 100,
166            temperature: 0.1,
167            system_prompt: None,
168        }
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn test_llm_error_transient() {
178        let err = LlmError::transient("connection timeout");
179        assert!(err.is_transient());
180        assert_eq!(err.message(), "connection timeout");
181        assert_eq!(
182            format!("{}", err),
183            "LLM error (transient): connection timeout"
184        );
185    }
186
187    #[test]
188    fn test_llm_error_permanent() {
189        let err = LlmError::permanent("invalid model");
190        assert!(!err.is_transient());
191        assert_eq!(err.message(), "invalid model");
192    }
193
194    #[test]
195    fn test_llm_decider_config_default() {
196        let config = LlmDeciderConfig::default();
197        assert_eq!(config.model, "qwen2.5-coder:1.5b");
198        assert_eq!(config.endpoint, "http://localhost:11434");
199        assert_eq!(config.timeout_ms, 5000);
200        assert_eq!(config.max_batch_size, 100);
201        assert!((config.temperature - 0.1).abs() < 0.001);
202    }
203}