Skip to main content

swarm_engine_core/
actions.rs

1//! Actions 統一管理
2//!
3//! Orchestrator 内で使用される Action を Protocol として統一管理する。
4//! Group 機能により、コンテキストに応じた Action セットを取得できる。
5//!
6//! # 設計
7//!
8//! ```text
9//! ActionsConfig
10//! ├─ actions: HashMap<String, ActionDef>
11//! │   └─ "read_file" → ActionDef { groups: ["file_ops", "exploration"] }
12//! │   └─ "grep"      → ActionDef { groups: ["search", "exploration"] }
13//! │   └─ "write"     → ActionDef { groups: ["file_ops", "mutation"] }
14//! │
15//! └─ groups: HashMap<String, ActionGroup>
16//!     └─ "readonly"  → ActionGroup { include: ["exploration"], exclude: ["mutation"] }
17//!     └─ "all"       → ActionGroup { include: ["*"] }
18//! ```
19//!
20//! # 使用例
21//!
22//! ```ignore
23//! // 構築
24//! let cfg = ActionsConfig::new()
25//!     .action("read_file", ActionDef::new("ファイル読み込み").groups(["file_ops", "exploration"]))
26//!     .action("grep", ActionDef::new("パターン検索").groups(["search", "exploration"]))
27//!     .group("readonly", ActionGroup::include(["exploration"]).exclude(["mutation"]));
28//!
29//! // Extensions に登録
30//! let orc = OrchestratorBuilder::new()
31//!     .extension(cfg)
32//!     .build(runtime);
33//!
34//! // Manager から使用
35//! let cfg = state.shared.extensions.get::<ActionsConfig>()?;
36//! let candidates = cfg.candidates_for("readonly");  // → ["read_file", "grep"]
37//! ```
38
39use std::collections::HashMap;
40use std::time::Duration;
41
42use serde::{Deserialize, Serialize};
43
44// ============================================================================
45// Action Types
46// ============================================================================
47
48/// Action のカテゴリ(探索空間への影響で分類)
49///
50/// # カテゴリ説明
51///
52/// - **NodeExpand**: 新しい探索対象を発見する(例: Grep, List)
53///   - 成功時: 発見した対象を新しい Node として追加
54///   - 探索グラフが拡張される
55///
56/// - **NodeStateChange**: 既存 Node の状態を遷移させる(例: Read)
57///   - 成功時: 既存 Node の状態を Explored/Completed に更新
58///   - 探索グラフは拡張されない
59///
60/// # 例: ファイル探索タスク
61///
62/// ```text
63/// Grep("auth") [NodeExpand]
64///   → 発見: src/auth.rs → 新 Node 追加
65///
66/// Read("src/auth.rs") [NodeStateChange]
67///   → src/auth.rs Node の状態を Explored に更新
68///   → 目標ファイルなら Completed
69/// ```
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
71pub enum ActionCategory {
72    /// 新しい探索対象を発見する(Grep, List など)
73    #[default]
74    NodeExpand,
75    /// 既存 Node の状態を遷移させる(Read など)
76    NodeStateChange,
77}
78
79/// Action - Agent が実行する処理
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct Action {
82    pub name: String,
83    pub params: ActionParams,
84}
85
86/// Action パラメータ
87#[derive(Debug, Clone, Default, Serialize, Deserialize)]
88pub struct ActionParams {
89    /// ターゲット(ファイルパス等)
90    pub target: Option<String>,
91    /// 引数
92    pub args: HashMap<String, String>,
93    /// 生データ
94    pub data: Vec<u8>,
95}
96
97// ============================================================================
98// ActionOutput - 型付き出力表現
99// ============================================================================
100
101/// Action 出力の型付き表現
102///
103/// `Box<dyn Any>` の代わりに明示的な型を使用することで:
104/// - Clone 可能
105/// - パターンマッチで安全なアクセス
106/// - 下流処理が明確化
107#[derive(Debug, Clone)]
108pub enum ActionOutput {
109    /// プレーンテキスト出力(stdout、ファイル内容、メッセージ等)
110    Text(String),
111
112    /// 構造化データ出力(観測結果、クエリ結果等)
113    Structured(serde_json::Value),
114
115    /// バイナリ出力(画像、ファイル等、将来用)
116    Binary(Vec<u8>),
117}
118
119impl ActionOutput {
120    /// テキストとして取得(Structured は JSON 文字列化)
121    pub fn as_text(&self) -> String {
122        match self {
123            Self::Text(s) => s.clone(),
124            Self::Structured(v) => v.to_string(),
125            Self::Binary(b) => format!("<binary: {} bytes>", b.len()),
126        }
127    }
128
129    /// 構造化データとして取得(Text は parse 試行)
130    pub fn as_structured(&self) -> Option<serde_json::Value> {
131        match self {
132            Self::Text(s) => serde_json::from_str(s).ok(),
133            Self::Structured(v) => Some(v.clone()),
134            Self::Binary(_) => None,
135        }
136    }
137
138    /// テキスト参照を取得(Text の場合のみ)
139    pub fn text(&self) -> Option<&str> {
140        match self {
141            Self::Text(s) => Some(s),
142            _ => None,
143        }
144    }
145
146    /// 構造化データ参照を取得(Structured の場合のみ)
147    pub fn structured(&self) -> Option<&serde_json::Value> {
148        match self {
149            Self::Structured(v) => Some(v),
150            _ => None,
151        }
152    }
153}
154
155// ============================================================================
156// ActionResult
157// ============================================================================
158
159/// Action 実行結果
160#[derive(Debug, Clone)]
161pub struct ActionResult {
162    pub success: bool,
163    pub output: Option<ActionOutput>,
164    pub duration: Duration,
165    pub error: Option<String>,
166    /// 発見したターゲット(ExploMap で新しいノードとして展開される)
167    ///
168    /// Search 系アクションで複数の結果を返す場合に使用。
169    /// 例: Search が ["doc1", "doc2", "doc3"] を返すと、
170    /// ExploMap がそれぞれを新しいコンテキストとして展開する。
171    pub discovered_targets: Vec<String>,
172}
173
174impl ActionResult {
175    /// テキスト出力で成功
176    pub fn success_text(output: impl Into<String>, duration: Duration) -> Self {
177        Self {
178            success: true,
179            output: Some(ActionOutput::Text(output.into())),
180            duration,
181            error: None,
182            discovered_targets: Vec::new(),
183        }
184    }
185
186    /// 構造化データ出力で成功
187    pub fn success_structured(output: serde_json::Value, duration: Duration) -> Self {
188        Self {
189            success: true,
190            output: Some(ActionOutput::Structured(output)),
191            duration,
192            error: None,
193            discovered_targets: Vec::new(),
194        }
195    }
196
197    /// バイナリ出力で成功
198    pub fn success_binary(output: Vec<u8>, duration: Duration) -> Self {
199        Self {
200            success: true,
201            output: Some(ActionOutput::Binary(output)),
202            duration,
203            error: None,
204            discovered_targets: Vec::new(),
205        }
206    }
207
208    /// 後方互換: 文字列出力で成功(success_text のエイリアス)
209    pub fn success(output: impl Into<String>, duration: Duration) -> Self {
210        Self::success_text(output, duration)
211    }
212
213    /// 失敗
214    pub fn failure(error: String, duration: Duration) -> Self {
215        Self {
216            success: false,
217            output: None,
218            duration,
219            error: Some(error),
220            discovered_targets: Vec::new(),
221        }
222    }
223
224    /// 発見したターゲットを設定(Builder パターン)
225    pub fn with_discoveries(mut self, targets: Vec<String>) -> Self {
226        self.discovered_targets = targets;
227        self
228    }
229}
230
231// ============================================================================
232// ActionDef - Action 定義
233// ============================================================================
234
235/// パラメータ定義
236#[derive(Debug, Clone)]
237pub struct ParamDef {
238    /// パラメータ名
239    pub name: String,
240    /// 説明
241    pub description: String,
242    /// 必須かどうか
243    pub required: bool,
244}
245
246impl ParamDef {
247    pub fn required(name: impl Into<String>, description: impl Into<String>) -> Self {
248        Self {
249            name: name.into(),
250            description: description.into(),
251            required: true,
252        }
253    }
254
255    pub fn optional(name: impl Into<String>, description: impl Into<String>) -> Self {
256        Self {
257            name: name.into(),
258            description: description.into(),
259            required: false,
260        }
261    }
262}
263
264/// パラメータバリアント定義
265///
266/// ExplorationSpace がアクションの後続ノードを展開する際、
267/// 指定されたパラメータ値のバリエーションを自動生成する。
268#[derive(Debug, Clone)]
269pub struct ParamVariants {
270    /// パラメータのキー名(例: "target", "direction")
271    pub key: String,
272    /// 取り得る値のリスト(例: ["north", "south", "east", "west"])
273    pub values: Vec<String>,
274}
275
276impl ParamVariants {
277    /// 新しい ParamVariants を作成
278    pub fn new(key: impl Into<String>, values: Vec<String>) -> Self {
279        Self {
280            key: key.into(),
281            values,
282        }
283    }
284}
285
286/// Action 定義
287#[derive(Debug, Clone)]
288pub struct ActionDef {
289    /// Action 名
290    pub name: String,
291    /// 説明(LLM プロンプト用)
292    pub description: String,
293    /// カテゴリ(探索空間への影響)
294    pub category: ActionCategory,
295    /// 所属 Group
296    pub groups: Vec<String>,
297    /// パラメータ定義
298    pub params: Vec<ParamDef>,
299    /// 出力例(LLM プロンプト用 JSON 形式)
300    pub example: Option<String>,
301    /// パラメータバリアント(ExplorationSpace で自動展開)
302    ///
303    /// 例: Move アクションで direction に対して ["north", "south", "east", "west"] を指定すると、
304    /// ExplorationSpace が後続ノード展開時に 4 つのバリアントを生成する。
305    ///
306    /// - `param_key`: パラメータ名(例: "target", "direction")
307    /// - `variants`: 取り得る値のリスト
308    pub param_variants: Option<ParamVariants>,
309}
310
311impl ActionDef {
312    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
313        Self {
314            name: name.into(),
315            description: description.into(),
316            category: ActionCategory::default(),
317            groups: Vec::new(),
318            params: Vec::new(),
319            example: None,
320            param_variants: None,
321        }
322    }
323
324    /// パラメータバリアントを設定
325    ///
326    /// ExplorationSpace が後続ノード展開時に、指定されたバリアントを自動生成する。
327    ///
328    /// # Example
329    ///
330    /// ```ignore
331    /// ActionDef::new("Move", "Move to adjacent cell")
332    ///     .param_variants("target", vec!["north", "south", "east", "west"])
333    /// ```
334    pub fn param_variants(
335        mut self,
336        key: impl Into<String>,
337        values: impl IntoIterator<Item = impl Into<String>>,
338    ) -> Self {
339        self.param_variants = Some(ParamVariants::new(
340            key,
341            values.into_iter().map(|v| v.into()).collect(),
342        ));
343        self
344    }
345
346    /// カテゴリを設定
347    pub fn category(mut self, category: ActionCategory) -> Self {
348        self.category = category;
349        self
350    }
351
352    /// NodeExpand カテゴリに設定(新しい探索対象を発見する Action)
353    pub fn node_expand(mut self) -> Self {
354        self.category = ActionCategory::NodeExpand;
355        self
356    }
357
358    /// NodeStateChange カテゴリに設定(既存 Node の状態を遷移させる Action)
359    pub fn node_state_change(mut self) -> Self {
360        self.category = ActionCategory::NodeStateChange;
361        self
362    }
363
364    /// Group を設定
365    pub fn groups<I, S>(mut self, groups: I) -> Self
366    where
367        I: IntoIterator<Item = S>,
368        S: Into<String>,
369    {
370        self.groups = groups.into_iter().map(|s| s.into()).collect();
371        self
372    }
373
374    /// Group を追加
375    pub fn group(mut self, group: impl Into<String>) -> Self {
376        self.groups.push(group.into());
377        self
378    }
379
380    /// パラメータを追加
381    pub fn param(mut self, param: ParamDef) -> Self {
382        self.params.push(param);
383        self
384    }
385
386    /// 必須パラメータを追加
387    pub fn required_param(self, name: impl Into<String>, description: impl Into<String>) -> Self {
388        self.param(ParamDef::required(name, description))
389    }
390
391    /// オプションパラメータを追加
392    pub fn optional_param(self, name: impl Into<String>, description: impl Into<String>) -> Self {
393        self.param(ParamDef::optional(name, description))
394    }
395
396    /// 出力例を設定(LLM プロンプト用 JSON 形式)
397    pub fn example(mut self, example: impl Into<String>) -> Self {
398        self.example = Some(example.into());
399        self
400    }
401
402    /// 指定した Group に所属しているか
403    pub fn has_group(&self, group: &str) -> bool {
404        self.groups.iter().any(|g| g == group)
405    }
406
407    /// 指定した Group のいずれかに所属しているか
408    pub fn has_any_group(&self, groups: &[&str]) -> bool {
409        groups.iter().any(|g| self.has_group(g))
410    }
411}
412
413// ============================================================================
414// ActionGroup - Action グループ(フィルタリング用)
415// ============================================================================
416
417/// Action グループ定義
418///
419/// include/exclude で Action をフィルタリングする。
420/// include が空の場合は全 Action が対象。
421#[derive(Debug, Clone, Default)]
422pub struct ActionGroup {
423    /// グループ名
424    pub name: String,
425    /// 含める Group(これらの Group を持つ Action を含む)
426    pub include_groups: Vec<String>,
427    /// 除外する Group(これらの Group を持つ Action を除外)
428    pub exclude_groups: Vec<String>,
429}
430
431impl ActionGroup {
432    pub fn new(name: impl Into<String>) -> Self {
433        Self {
434            name: name.into(),
435            include_groups: Vec::new(),
436            exclude_groups: Vec::new(),
437        }
438    }
439
440    /// 含める Group を設定
441    pub fn include<I, S>(mut self, groups: I) -> Self
442    where
443        I: IntoIterator<Item = S>,
444        S: Into<String>,
445    {
446        self.include_groups = groups.into_iter().map(|s| s.into()).collect();
447        self
448    }
449
450    /// 除外する Group を設定
451    pub fn exclude<I, S>(mut self, groups: I) -> Self
452    where
453        I: IntoIterator<Item = S>,
454        S: Into<String>,
455    {
456        self.exclude_groups = groups.into_iter().map(|s| s.into()).collect();
457        self
458    }
459
460    /// ActionDef がこの Group にマッチするか判定
461    pub fn matches(&self, action: &ActionDef) -> bool {
462        // exclude チェック(1つでも該当すれば除外)
463        if self.exclude_groups.iter().any(|g| action.has_group(g)) {
464            return false;
465        }
466
467        // include が空なら全て含む
468        if self.include_groups.is_empty() {
469            return true;
470        }
471
472        // include チェック(1つでも該当すれば含む)
473        self.include_groups.iter().any(|g| action.has_group(g))
474    }
475}
476
477// ============================================================================
478// ActionsConfig - Actions 統一管理
479// ============================================================================
480
481/// Actions 統一管理
482///
483/// Orchestrator 内で使用される Action を Protocol として統一管理する。
484/// Extensions に登録して Manager/Worker から参照する。
485#[derive(Debug, Clone, Default)]
486pub struct ActionsConfig {
487    /// Action 定義
488    actions: HashMap<String, ActionDef>,
489    /// Group 定義
490    groups: HashMap<String, ActionGroup>,
491}
492
493impl ActionsConfig {
494    pub fn new() -> Self {
495        Self::default()
496    }
497
498    /// Action を追加
499    pub fn action(mut self, name: impl Into<String>, def: ActionDef) -> Self {
500        let name = name.into();
501        let mut def = def;
502        def.name = name.clone();
503        self.actions.insert(name, def);
504        self
505    }
506
507    /// Action を追加(mutable)
508    pub fn add_action(&mut self, name: impl Into<String>, def: ActionDef) {
509        let name = name.into();
510        let mut def = def;
511        def.name = name.clone();
512        self.actions.insert(name, def);
513    }
514
515    /// Group を追加
516    pub fn group(mut self, name: impl Into<String>, group: ActionGroup) -> Self {
517        let name = name.into();
518        let mut group = group;
519        group.name = name.clone();
520        self.groups.insert(name, group);
521        self
522    }
523
524    /// Group を追加(mutable)
525    pub fn add_group(&mut self, name: impl Into<String>, group: ActionGroup) {
526        let name = name.into();
527        let mut group = group;
528        group.name = name.clone();
529        self.groups.insert(name, group);
530    }
531
532    // ========================================================================
533    // Query API
534    // ========================================================================
535
536    /// 全 Action 名を取得
537    pub fn all_action_names(&self) -> Vec<String> {
538        self.actions.keys().cloned().collect()
539    }
540
541    /// 全 Action 定義を取得
542    pub fn all_actions(&self) -> impl Iterator<Item = &ActionDef> {
543        self.actions.values()
544    }
545
546    /// Action 定義を取得
547    pub fn get(&self, name: &str) -> Option<&ActionDef> {
548        self.actions.get(name)
549    }
550
551    /// Group 定義を取得
552    pub fn get_group(&self, name: &str) -> Option<&ActionGroup> {
553        self.groups.get(name)
554    }
555
556    /// 指定 Group に所属する Action を取得
557    pub fn by_group(&self, group_name: &str) -> Vec<&ActionDef> {
558        if let Some(group) = self.groups.get(group_name) {
559            self.actions.values().filter(|a| group.matches(a)).collect()
560        } else {
561            // Group 定義がない場合は、直接その group を持つ Action を返す
562            self.actions
563                .values()
564                .filter(|a| a.has_group(group_name))
565                .collect()
566        }
567    }
568
569    /// 指定 Group の Action 名リストを取得(LLM candidates 用)
570    pub fn candidates_for(&self, group_name: &str) -> Vec<String> {
571        self.by_group(group_name)
572            .into_iter()
573            .map(|a| a.name.clone())
574            .collect()
575    }
576
577    /// 複数 Group のいずれかに所属する Action を取得
578    pub fn by_groups(&self, group_names: &[&str]) -> Vec<&ActionDef> {
579        self.actions
580            .values()
581            .filter(|a| group_names.iter().any(|g| a.has_group(g)))
582            .collect()
583    }
584
585    /// 複数 Group の Action 名リストを取得
586    pub fn candidates_by_groups(&self, group_names: &[&str]) -> Vec<String> {
587        self.by_groups(group_names)
588            .into_iter()
589            .map(|a| a.name.clone())
590            .collect()
591    }
592
593    /// NodeExpand カテゴリのアクション名を取得
594    ///
595    /// 探索系アクション(新しい探索対象を発見する)のみを返す。
596    /// 初期展開時に使用する。
597    pub fn node_expand_actions(&self) -> Vec<String> {
598        self.actions
599            .values()
600            .filter(|a| a.category == ActionCategory::NodeExpand)
601            .map(|a| a.name.clone())
602            .collect()
603    }
604
605    /// NodeStateChange カテゴリのアクション名を取得
606    pub fn node_state_change_actions(&self) -> Vec<String> {
607        self.actions
608            .values()
609            .filter(|a| a.category == ActionCategory::NodeStateChange)
610            .map(|a| a.name.clone())
611            .collect()
612    }
613
614    /// 指定アクションのパラメータバリアントを取得
615    ///
616    /// ExplorationSpace がノード展開時にバリアントを生成するために使用。
617    ///
618    /// # Returns
619    ///
620    /// - `Some((key, values))`: パラメータバリアントが定義されている場合
621    /// - `None`: 定義されていない場合
622    pub fn param_variants(&self, action_name: &str) -> Option<(&str, &[String])> {
623        self.actions
624            .get(action_name)
625            .and_then(|a| a.param_variants.as_ref())
626            .map(|pv| (pv.key.as_str(), pv.values.as_slice()))
627    }
628
629    // ========================================================================
630    // Action Builder
631    // ========================================================================
632
633    /// Action を構築
634    ///
635    /// DecisionResponse から Action を構築する際に使用。
636    /// 登録されていない Action 名の場合は None を返す。
637    pub fn build_action(
638        &self,
639        name: &str,
640        target: Option<String>,
641        args: HashMap<String, String>,
642    ) -> Option<Action> {
643        self.actions.get(name).map(|_def| Action {
644            name: name.to_string(),
645            params: ActionParams {
646                target,
647                args,
648                data: Vec::new(),
649            },
650        })
651    }
652
653    /// Action を構築(登録されていなくても作成)
654    ///
655    /// バリデーションなしで Action を作成する。
656    /// 動的な Action 名に対応する場合に使用。
657    pub fn build_action_unchecked(
658        &self,
659        name: impl Into<String>,
660        target: Option<String>,
661        args: HashMap<String, String>,
662    ) -> Action {
663        Action {
664            name: name.into(),
665            params: ActionParams {
666                target,
667                args,
668                data: Vec::new(),
669            },
670        }
671    }
672
673    // ========================================================================
674    // Validation
675    // ========================================================================
676
677    /// Action をバリデート
678    pub fn validate(&self, action: &Action) -> Result<(), ActionValidationError> {
679        let def = self
680            .actions
681            .get(&action.name)
682            .ok_or_else(|| ActionValidationError::UnknownAction(action.name.clone()))?;
683
684        // 必須パラメータチェック
685        for param in &def.params {
686            if param.required && !action.params.args.contains_key(&param.name) {
687                return Err(ActionValidationError::MissingParam(param.name.clone()));
688            }
689        }
690
691        Ok(())
692    }
693}
694
695/// Action バリデーションエラー
696#[derive(Debug, Clone, thiserror::Error)]
697pub enum ActionValidationError {
698    #[error("Unknown action: {0}")]
699    UnknownAction(String),
700
701    #[error("Missing required parameter: {0}")]
702    MissingParam(String),
703
704    #[error("Invalid parameter value: {0}")]
705    InvalidParam(String),
706}
707
708// ============================================================================
709// Tests
710// ============================================================================
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715
716    fn sample_config() -> ActionsConfig {
717        ActionsConfig::new()
718            .action(
719                "read_file",
720                ActionDef::new("read_file", "ファイルを読み込む")
721                    .groups(["file_ops", "exploration"])
722                    .required_param("path", "ファイルパス"),
723            )
724            .action(
725                "grep",
726                ActionDef::new("grep", "パターン検索")
727                    .groups(["search", "exploration"])
728                    .required_param("pattern", "検索パターン"),
729            )
730            .action(
731                "write_file",
732                ActionDef::new("write_file", "ファイルを書き込む")
733                    .groups(["file_ops", "mutation"])
734                    .required_param("path", "ファイルパス")
735                    .required_param("content", "内容"),
736            )
737            .action(
738                "escalate",
739                ActionDef::new("escalate", "Manager に報告").groups(["control"]),
740            )
741            .group(
742                "readonly",
743                ActionGroup::new("readonly")
744                    .include(["exploration", "search"])
745                    .exclude(["mutation"]),
746            )
747            .group(
748                "all",
749                ActionGroup::new("all"), // include/exclude なし = 全部
750            )
751    }
752
753    #[test]
754    fn test_by_group_direct() {
755        let cfg = sample_config();
756
757        // 直接 group 名で取得
758        let file_ops = cfg.by_group("file_ops");
759        assert_eq!(file_ops.len(), 2);
760
761        let exploration = cfg.by_group("exploration");
762        assert_eq!(exploration.len(), 2);
763    }
764
765    #[test]
766    fn test_by_group_defined() {
767        let cfg = sample_config();
768
769        // 定義された Group で取得
770        let readonly = cfg.candidates_for("readonly");
771        assert!(readonly.contains(&"read_file".to_string()));
772        assert!(readonly.contains(&"grep".to_string()));
773        assert!(!readonly.contains(&"write_file".to_string())); // mutation なので除外
774
775        let all = cfg.candidates_for("all");
776        assert_eq!(all.len(), 4);
777    }
778
779    #[test]
780    fn test_build_action() {
781        let cfg = sample_config();
782
783        let action = cfg.build_action(
784            "read_file",
785            Some("/path/to/file".to_string()),
786            HashMap::new(),
787        );
788        assert!(action.is_some());
789        assert_eq!(action.unwrap().name, "read_file");
790
791        let unknown = cfg.build_action("unknown", None, HashMap::new());
792        assert!(unknown.is_none());
793    }
794
795    #[test]
796    fn test_validate() {
797        let cfg = sample_config();
798
799        // 有効な Action
800        let action = Action {
801            name: "read_file".to_string(),
802            params: ActionParams {
803                target: None,
804                args: [("path".to_string(), "/tmp/test".to_string())]
805                    .into_iter()
806                    .collect(),
807                data: Vec::new(),
808            },
809        };
810        assert!(cfg.validate(&action).is_ok());
811
812        // 必須パラメータ不足
813        let action_missing = Action {
814            name: "read_file".to_string(),
815            params: ActionParams::default(),
816        };
817        assert!(matches!(
818            cfg.validate(&action_missing),
819            Err(ActionValidationError::MissingParam(_))
820        ));
821
822        // 未知の Action
823        let unknown = Action {
824            name: "unknown".to_string(),
825            params: ActionParams::default(),
826        };
827        assert!(matches!(
828            cfg.validate(&unknown),
829            Err(ActionValidationError::UnknownAction(_))
830        ));
831    }
832}