Skip to main content

agentmux_srv/server/agent_handlers/
skills.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4
5use std::sync::Arc;
6
7
8use crate::backend::rpc::engine::WshRpcEngine;
9use crate::backend::rpc_types::{
10    COMMAND_LIST_AGENT_SKILLS, COMMAND_CREATE_AGENT_SKILL, COMMAND_UPDATE_AGENT_SKILL,
11    COMMAND_DELETE_AGENT_SKILL,
12    CommandListAgentSkillsData, CommandCreateAgentSkillData, CommandUpdateAgentSkillData,
13    CommandDeleteAgentSkillData,
14};
15use crate::backend::storage::AgentSkill;
16
17use super::super::AppState;
18
19pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
20    // listagentskills → return this agent's EFFECTIVE skills (legacy
21    // db_agent_skills, or its own ref-bound db_skills + globals when any own
22    // refs exist — see Store::effective_skills). Window-scoped (no
23    // `check_s1`, hence `_ctx` unused) because this is called from
24    // `agent-model.ts`'s pre-launch `launchAgentDefinition`, before any
25    // agent connection exists to authenticate as — that's also exactly why
26    // this must reuse the same merge algorithm as the Rust
27    // `write_agent_config_files` path rather than the frontend calling the
28    // agent-scoped `skill.list` RPC directly (it would fail check_s1 pre-
29    // launch). Previously returned only agent_skill_list (legacy-only),
30    // silently hiding every standalone/Memory → Skills skill from the actual
31    // launch flow (reagent P0 on PR #2322).
32    let mstore_lfs = state.mstore.clone();
33    let identity_store_lfs = state.identity_store.clone();
34    engine.register_typed(
35        COMMAND_LIST_AGENT_SKILLS,
36        move |cmd: CommandListAgentSkillsData, _ctx| {
37            let mstore = mstore_lfs.clone();
38            let identity_store = identity_store_lfs.clone();
39            async move {
40                let skills = mstore.effective_skills(&identity_store, &cmd.agent_id);
41                Ok(skills)
42            }
43        },
44    );
45
46    // createagentskill → insert new skill, broadcast agentskills:changed
47    let mstore_cfs = state.mstore.clone();
48    let broker_cfs = state.broker.clone();
49    engine.register_typed(
50        COMMAND_CREATE_AGENT_SKILL,
51        move |cmd: CommandCreateAgentSkillData, _ctx| {
52            let mstore = mstore_cfs.clone();
53            let broker = broker_cfs.clone();
54            async move {
55                let now = agentmux_common::time::now_ms();
56                let skill = AgentSkill {
57                    id: uuid::Uuid::new_v4().to_string(),
58                    agent_id: cmd.agent_id,
59                    name: cmd.name,
60                    trigger: cmd.trigger,
61                    skill_type: cmd.skill_type,
62                    description: cmd.description,
63                    content: cmd.content,
64                    created_at: now,
65                };
66                mstore.agent_skill_insert(&skill).map_err(|e| format!("createagentskill: {e}"))?;
67                broker.publish_event::<crate::backend::mps_events::AgentSkillsChanged>(&(), vec![], 0);
68                Ok(skill)
69            }
70        },
71    );
72
73    // updateagentskill → update existing skill, broadcast agentskills:changed
74    let mstore_ufs = state.mstore.clone();
75    let broker_ufs = state.broker.clone();
76    engine.register_typed(
77        COMMAND_UPDATE_AGENT_SKILL,
78        move |cmd: CommandUpdateAgentSkillData, _ctx| {
79            let mstore = mstore_ufs.clone();
80            let broker = broker_ufs.clone();
81            async move {
82                let existing = mstore.agent_skill_get(&cmd.id)
83                    .map_err(|e| format!("updateagentskill: {e}"))?
84                    .ok_or_else(|| format!("updateagentskill: skill {} not found", cmd.id))?;
85                let skill = AgentSkill {
86                    id: cmd.id,
87                    agent_id: existing.agent_id,
88                    name: cmd.name,
89                    trigger: cmd.trigger,
90                    skill_type: cmd.skill_type,
91                    description: cmd.description,
92                    content: cmd.content,
93                    created_at: existing.created_at,
94                };
95                let found = mstore.agent_skill_update(&skill).map_err(|e| format!("updateagentskill: {e}"))?;
96                if !found {
97                    return Err(format!("updateagentskill: skill {} not found", skill.id));
98                }
99                broker.publish_event::<crate::backend::mps_events::AgentSkillsChanged>(&(), vec![], 0);
100                Ok(skill)
101            }
102        },
103    );
104
105    // deleteagentskill → delete skill by id, broadcast agentskills:changed
106    let mstore_dfs = state.mstore.clone();
107    let broker_dfs = state.broker.clone();
108    engine.register_typed(
109        COMMAND_DELETE_AGENT_SKILL,
110        move |cmd: CommandDeleteAgentSkillData, _ctx| {
111            let mstore = mstore_dfs.clone();
112            let broker = broker_dfs.clone();
113            async move {
114                mstore.agent_skill_delete(&cmd.id).map_err(|e| format!("deleteagentskill: {e}"))?;
115                broker.publish_event::<crate::backend::mps_events::AgentSkillsChanged>(&(), vec![], 0);
116                Ok(())
117            }
118        },
119    );
120
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::backend::rpc_types::{
127        CommandCreateAgentSkillData, CommandUpdateAgentSkillData,
128    };
129
130    /// All four skill commands record their request and response types.
131    #[tokio::test]
132    async fn register_typed_records_every_skill_command() {
133        let state = crate::server::tests::test_state();
134        let (engine, _rx) = WshRpcEngine::new();
135        register(&engine, &state);
136        let schema = engine.schema_json();
137        let rows = schema.as_array().unwrap();
138        let find = |cmd: &str| {
139            rows.iter()
140                .find(|r| r["command"] == cmd)
141                .unwrap_or_else(|| panic!("{cmd} missing from the schema"))
142        };
143
144        for (cmd, req, resp) in [
145            (COMMAND_CREATE_AGENT_SKILL, "CommandCreateAgentSkillData", "AgentSkill"),
146            (COMMAND_UPDATE_AGENT_SKILL, "CommandUpdateAgentSkillData", "AgentSkill"),
147            (COMMAND_DELETE_AGENT_SKILL, "CommandDeleteAgentSkillData", "()"),
148        ] {
149            let row = find(cmd);
150            assert_eq!(row["requestName"], req, "{cmd} request");
151            assert_eq!(row["responseName"], resp, "{cmd} response");
152        }
153
154        let list = find(COMMAND_LIST_AGENT_SKILLS);
155        assert_eq!(list["requestName"], "CommandListAgentSkillsData");
156        let list_resp = list["responseName"].as_str().unwrap();
157        assert!(
158            list_resp.starts_with("alloc::vec::Vec<") && list_resp.contains("AgentSkill"),
159            "listagentskills should answer Vec<AgentSkill>, got {list_resp:?}",
160        );
161    }
162
163    /// `skill_type` defaults differently on create and update, and the
164    /// hand-written TS typed both `skill_type?: string`, which hid it.
165    ///
166    /// `createagentskill` fills in "prompt"; `updateagentskill` fills in "".
167    /// Since update is a full replace -- it rebuilds the whole `AgentSkill`
168    /// from the request and keeps only `agent_id`/`created_at` -- a
169    /// create-then-update round trip that omits the field silently blanks a
170    /// value the create had set.
171    ///
172    /// This is documenting existing behaviour rather than changing it: the
173    /// fix is a product decision and `updateagentskill` has no caller today.
174    /// What this test does is make the asymmetry fail loudly if someone
175    /// "tidies" one of the two defaults without meaning to change the other.
176    #[test]
177    fn create_and_update_disagree_about_the_default_skill_type() {
178        let created: CommandCreateAgentSkillData = serde_json::from_value(serde_json::json!({
179            "agent_id": "a1",
180            "name": "review",
181        }))
182        .unwrap();
183        assert_eq!(created.skill_type, "prompt", "createagentskill defaults to prompt");
184
185        let updated: CommandUpdateAgentSkillData = serde_json::from_value(serde_json::json!({
186            "id": "s1",
187            "name": "review",
188        }))
189        .unwrap();
190        assert_eq!(
191            updated.skill_type, "",
192            "updateagentskill defaults to empty -- if this ever becomes \"prompt\", \
193             the two are consistent and the warning on CommandUpdateAgentSkillData \
194             should be removed with it",
195        );
196
197        // The other three content fields agree: both default to empty.
198        for (c, u) in [
199            (created.trigger.as_str(), updated.trigger.as_str()),
200            (created.description.as_str(), updated.description.as_str()),
201            (created.content.as_str(), updated.content.as_str()),
202        ] {
203            assert_eq!(c, "");
204            assert_eq!(u, "");
205        }
206    }
207}