Skip to main content

headless_lms_mcp/
hints.rs

1//! Just-in-time hints placed inside structured results. Hints state facts; they never tell the
2//! agent to run a command (the skill holds the procedures).
3
4use headless_lms_utils::version::Version;
5use serde::Serialize;
6
7use crate::page_readiness::PlaceholderText;
8use crate::protocol::context::ClientKind;
9use crate::validator::BlockSchemas;
10
11/// The skill version this server is built for.
12pub const CURRENT_SKILL_VERSION: Version = Version(0, 0, 0);
13/// The oldest skill version this server still works with.
14pub const MINIMUM_SKILL_VERSION: Version = Version(0, 0, 0);
15/// Where teachers learn to set up the skill.
16pub const SKILL_SETUP_URL: &str =
17    "https://github.com/rage/course-material-agent-skills/blob/main/SETUP.md";
18
19/// What a [`Hint`] tells the agent.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "snake_case")]
22pub enum HintKind {
23    FormatTip,
24    State,
25    Consequence,
26    SkillVersion,
27    VisualCheck,
28}
29
30/// A fact for the agent, placed in the structured result.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
32pub struct Hint {
33    pub kind: HintKind,
34    pub text: String,
35}
36
37impl Hint {
38    fn new(kind: HintKind, text: impl Into<String>) -> Self {
39        Self {
40            kind,
41            text: text.into(),
42        }
43    }
44}
45
46/// The `skill_version` an agent sends once the teacher has said no to installing the skill.
47pub const SKILL_DECLINED: &str = "declined";
48
49/// Why a call is refused over the skill it reports.
50#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
51pub enum SkillRefusal {
52    /// The skill is older than [`MINIMUM_SKILL_VERSION`].
53    #[error("{0}")]
54    Outdated(String),
55    /// A call without `skill_version`; see [`skill_missing`].
56    #[error("{0}")]
57    Missing(String),
58}
59
60/// Checks the `skill_version` a call sent against [`CURRENT_SKILL_VERSION`] and
61/// [`MINIMUM_SKILL_VERSION`]. No hint means the skill is current or newer. A missing version only
62/// gives a hint here; tools that need the skill refuse it with [`skill_missing`].
63pub fn skill_version(reported: Option<&str>) -> Result<Option<Hint>, SkillRefusal> {
64    check_skill_version(reported, CURRENT_SKILL_VERSION, MINIMUM_SKILL_VERSION)
65}
66
67/// The refusal of a call that sent no `skill_version`, written for the agent to relay to the
68/// teacher.
69pub fn skill_missing() -> SkillRefusal {
70    // The install paths mirror the skill repository's SETUP.md.
71    SkillRefusal::Missing(format!(
72        "Nothing was done: no skill_version was sent, and this server can't be used safely without the course-material skill. \
73Ask the teacher whether it is fine to install the skill, and wait for their answer before continuing. \
74Tell them what it is: the course-material skill (https://github.com/rage/course-material-agent-skills) gives the agent the editing workflow, \
75the safety rules (what needs the teacher's approval, checking pages before publishing) and the formats of course content. \
76If the teacher agrees, install it: clone that repository into the user-level skills folder as course-material \
77(Claude Code: ~/.claude/skills/course-material, or skills/course-material in $CLAUDE_CONFIG_DIR if it is set; Codex: ~/.agents/skills/course-material), \
78read SKILL.md in it, then continue with the teacher's task, sending skill_version from the skill's VERSION file on every call. \
79The rest of the setup ({SKILL_SETUP_URL}) isn't needed for the task and can wait until it is done. \
80If the teacher says no, send skill_version \"{SKILL_DECLINED}\" on every call from then on; the tools then work without the skill."
81    ))
82}
83
84fn check_skill_version(
85    reported: Option<&str>,
86    current: Version,
87    minimum: Version,
88) -> Result<Option<Hint>, SkillRefusal> {
89    let Some(reported) = reported else {
90        return Ok(Some(Hint::new(
91            HintKind::SkillVersion,
92            format!(
93                "No skill_version was sent, so course tools are refused until the course-material skill is installed or the teacher declines it. The skill holds the workflow, the safety rules and the content formats; setup: {SKILL_SETUP_URL}"
94            ),
95        )));
96    };
97    if reported == SKILL_DECLINED {
98        return Ok(Some(Hint::new(
99            HintKind::SkillVersion,
100            "The teacher declined the course-material skill, so its workflow, safety rules and content formats aren't in use; guide has the block formats.",
101        )));
102    }
103    let Some(reported_version) = Version::parse(reported) else {
104        return Ok(Some(Hint::new(
105            HintKind::SkillVersion,
106            format!(
107                "skill_version \"{reported}\" isn't a version number; the current skill is {current}."
108            ),
109        )));
110    };
111    if reported_version < minimum {
112        return Err(SkillRefusal::Outdated(format!(
113            "Skill version {reported} is older than {minimum}, the oldest this server works with (current {current}). Update the skill as its update section describes, re-read SKILL.md, then repeat the call."
114        )));
115    }
116    if reported_version < current {
117        return Ok(Some(Hint::new(
118            HintKind::SkillVersion,
119            format!(
120                "Skill version {reported}; current {current}; see the skill's update section. An outdated skill can produce corrupted content."
121            ),
122        )));
123    }
124    Ok(None)
125}
126
127/// Changes are staged and wait for publishing. `is_draft`: the course is a draft, which
128/// students don't see even after publishing.
129pub fn staged(changeset_id: uuid::Uuid, is_draft: bool) -> Hint {
130    let text = if is_draft {
131        format!(
132            "The changes are staged in changeset {changeset_id}. The course is a draft, so students see neither them nor the published course until the teacher takes the course out of draft."
133        )
134    } else {
135        format!(
136            "The changes are staged in changeset {changeset_id}; students don't see them until it is published."
137        )
138    };
139    Hint::new(HintKind::State, text)
140}
141
142/// Risky changes wait for the teacher on the review page.
143pub fn needs_teacher_review(review_url: &str, reason_count: usize) -> Hint {
144    Hint::new(
145        HintKind::State,
146        format!(
147            "{reason_count} staged change(s) are risky, so only the teacher can publish this changeset, on the review page: {review_url}"
148        ),
149    )
150}
151
152/// The changeset is published. `is_draft` as in [`staged`].
153pub fn published(changeset_id: uuid::Uuid, is_draft: bool) -> Hint {
154    let text = if is_draft {
155        format!(
156            "Changeset {changeset_id} is published to the draft course; students see the course once the teacher takes it out of draft."
157        )
158    } else {
159        format!("Changeset {changeset_id} is published; students see the changes now.")
160    };
161    Hint::new(HintKind::State, text)
162}
163
164/// Pages of the open changeset render differently from the published course.
165pub fn rendering_changes(page_count: usize, client: ClientKind) -> Hint {
166    let tooling = match client {
167        ClientKind::ClaudeCode | ClientKind::Codex => "playwright-cli and its skill",
168        ClientKind::Other => "a browser tool such as playwright-cli",
169    };
170    Hint::new(
171        HintKind::VisualCheck,
172        format!(
173            "{page_count} page(s) in the changeset have rendering changes; preview them before publishing. get_preview_link gives one preview URL for the whole changeset to open with {tooling}."
174        ),
175    )
176}
177
178/// Pages a just published changeset changed the rendering of.
179pub fn published_rendering_changes(page_count: usize) -> Hint {
180    Hint::new(
181        HintKind::VisualCheck,
182        format!(
183            "{page_count} published page(s) have rendering changes; any not previewed before publishing can be checked on the course site."
184        ),
185    )
186}
187
188/// The page still holds placeholder text from a default page template.
189pub fn template_placeholders(page_id: uuid::Uuid, placeholders: &[PlaceholderText]) -> Hint {
190    let blocks: Vec<String> = placeholders
191        .iter()
192        .map(|p| {
193            format!(
194                "{} {} {} \"{}\"",
195                p.block_name, p.block, p.attribute, p.text
196            )
197        })
198        .collect();
199    Hint::new(
200        HintKind::State,
201        format!(
202            "Page {page_id} still holds placeholder text from the default page template ({}); publish_changeset refuses it until the text is replaced or those blocks are removed.",
203            blocks.join("; ")
204        ),
205    )
206}
207
208/// Moved pages get redirects when the changeset is published.
209pub fn redirects_created(count: usize) -> Hint {
210    Hint::new(
211        HintKind::Consequence,
212        format!(
213            "{count} page path(s) changed; redirects from the old paths are created automatically when the changeset is published."
214        ),
215    )
216}
217
218/// Links to moved pages keep working through redirects.
219pub fn links_to_old_paths(count: usize) -> Hint {
220    Hint::new(
221        HintKind::Consequence,
222        format!(
223            "{count} link(s) in course content point at old paths; they keep working through the redirects."
224        ),
225    )
226}
227
228/// Links to paths that other pages now have open those pages instead.
229pub fn links_to_reused_paths(count: usize) -> Hint {
230    Hint::new(
231        HintKind::Consequence,
232        format!(
233            "{count} link(s) in course content point at old paths that other pages now have, so they will open those pages (now_opens_page in links_to_old_paths). Change them to the new paths if they should still open the moved pages."
234        ),
235    )
236}
237
238/// Copied exercises are new ones without submissions.
239pub fn exercises_copied(count: usize) -> Hint {
240    Hint::new(
241        HintKind::Consequence,
242        format!("{count} copied exercise(s) are new exercises with no submissions."),
243    )
244}
245
246/// Moved blocks keep their ids, so feedback and proposed edits follow them.
247pub fn blocks_moved_between_pages(count: usize) -> Hint {
248    Hint::new(
249        HintKind::Consequence,
250        format!(
251            "{count} block(s) moved to another page keep their ids; feedback and proposed edits on them follow."
252        ),
253    )
254}
255
256/// Switching an exercise's review config orphans the reviews students have given.
257pub fn review_config_switched_with_reviews(review_count: i64) -> Hint {
258    Hint::new(
259        HintKind::Consequence,
260        format!(
261            "Students have given {review_count} review(s) on this exercise. After the switch students no longer see those reviews and they no longer match the review questions; the teacher sees this warning on the review page."
262        ),
263    )
264}
265
266/// Task instructions edited on an exercise students have already answered.
267pub fn answered_task_instructions_edited() -> Hint {
268    Hint::new(
269        HintKind::Consequence,
270        "Students have already answered this exercise. Before staging the change, tell the teacher what it means for those earlier answers. Answer options and answer keys are in the task spec, which only the teacher can change, in the exercise editor; a changed question can leave them wrong.",
271    )
272}
273
274/// Removed exercises have student submissions.
275pub fn submissions_affected(count: usize) -> Hint {
276    Hint::new(
277        HintKind::Consequence,
278        format!("{count} removed exercise(s) have student submissions."),
279    )
280}
281
282/// Format facts for the block types in a result, from the CMS schema export. `block_names` are
283/// distinct.
284pub fn format_tips<'a>(
285    block_names: impl IntoIterator<Item = &'a str>,
286    schemas: &BlockSchemas,
287) -> Vec<Hint> {
288    block_names
289        .into_iter()
290        .filter_map(|name| {
291            let schema = schemas.blocks.get(name)?;
292            let rich_text: Vec<&str> = schema
293                .attributes
294                .iter()
295                .filter(|(_, s)| {
296                    s.get("x-rich-text").and_then(serde_json::Value::as_bool) == Some(true)
297                })
298                .map(|(attribute, _)| attribute.as_str())
299                .collect();
300            let mut facts = Vec::new();
301            if !rich_text.is_empty() {
302                facts.push(format!("rich text in {}", rich_text.join(", ")));
303            }
304            if let Some(children) = &schema.allowed_children {
305                if children.is_empty() {
306                    facts.push("no inner blocks".to_string());
307                } else {
308                    facts.push(format!("inner blocks: {}", children.join(", ")));
309                }
310            }
311            if schema
312                .allowed_parents
313                .as_ref()
314                .is_some_and(|p| p.iter().any(|p| p == "root"))
315            {
316                facts.push("top level only".to_string());
317            }
318            (!facts.is_empty()).then(|| {
319                Hint::new(
320                    HintKind::FormatTip,
321                    format!("{name}: {}.", facts.join("; ")),
322                )
323            })
324        })
325        .collect()
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn skill_versions() {
334        let missing = check_skill_version(None, Version(1, 6, 0), Version(1, 2, 0))
335            .unwrap()
336            .unwrap();
337        assert!(missing.text.contains("No skill_version was sent"));
338        let outdated = check_skill_version(Some("1.4.2"), Version(1, 6, 0), Version(1, 2, 0))
339            .unwrap()
340            .unwrap();
341        assert_eq!(
342            outdated.text,
343            "Skill version 1.4.2; current 1.6.0; see the skill's update section. An outdated skill can produce corrupted content."
344        );
345        assert!(
346            check_skill_version(Some("1.6.0"), Version(1, 6, 0), Version(1, 2, 0))
347                .unwrap()
348                .is_none()
349        );
350        assert!(
351            check_skill_version(Some("1.7.0"), Version(1, 6, 0), Version(1, 2, 0))
352                .unwrap()
353                .is_none()
354        );
355        assert!(
356            check_skill_version(Some("2.0"), Version(1, 6, 0), Version(1, 2, 0))
357                .unwrap()
358                .is_none()
359        );
360        let too_old =
361            check_skill_version(Some("1.1.9"), Version(1, 6, 0), Version(1, 2, 0)).unwrap_err();
362        assert!(too_old.to_string().contains("update section"));
363        let declined =
364            check_skill_version(Some(SKILL_DECLINED), Version(1, 6, 0), Version(1, 2, 0))
365                .unwrap()
366                .unwrap();
367        assert!(declined.text.contains("declined"));
368        for not_a_version in ["banana", "1.3.0-rc1", "1.x", "1.2.3.4"] {
369            let hint = check_skill_version(Some(not_a_version), Version(1, 6, 0), Version(1, 2, 0))
370                .unwrap()
371                .unwrap();
372            assert!(
373                hint.text.contains("isn't a version number"),
374                "{not_a_version}"
375            );
376        }
377        assert!(
378            skill_version(Some(&CURRENT_SKILL_VERSION.to_string()))
379                .unwrap()
380                .is_none()
381        );
382    }
383
384    #[test]
385    fn hints_state_facts_not_commands() {
386        let hints = [
387            rendering_changes(2, ClientKind::Codex),
388            redirects_created(3),
389            needs_teacher_review("https://x/review", 1),
390            skill_version(None).unwrap().unwrap(),
391            skill_version(Some(SKILL_DECLINED)).unwrap().unwrap(),
392        ];
393        for hint in hints {
394            let lower = hint.text.to_lowercase();
395            assert!(
396                !lower.contains("git pull") && !lower.starts_with("run "),
397                "{}",
398                hint.text
399            );
400        }
401    }
402}