Skip to main content

submilli_engine/runtime/
skills.rs

1//! The embedder-supplied seam for `submilli:skills`: a program reads the skills
2//! the harness offers, their instructions and the files bundled with them.
3//!
4//! Where skills come from is the harness's choice. Everything that crosses
5//! [`SkillProvider`] is owned, serializable data, so a harness may implement it
6//! in process or forward it over the wire.
7
8use std::future::Future;
9use std::pin::Pin;
10
11use serde::{Deserialize, Serialize};
12
13use crate::stdlib::shared::truncated;
14
15pub const SKILLS_MODULE_NAME: &str = "submilli:skills";
16
17/// One skill, as a listing shows it.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct SkillInfo {
20    pub name: String,
21    /// What the skill is for. The engine reduces it to one bounded line before a
22    /// program sees it in a listing.
23    pub description: Option<String>,
24}
25
26/// A loaded skill.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct Skill {
29    pub name: String,
30    pub description: Option<String>,
31    /// The skill's instructions.
32    pub content: String,
33}
34
35/// Why a skill or one of its files could not be read. Every message is safe to
36/// show the program.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub enum SkillError {
39    /// No [`SkillProvider`] is wired into the runtime.
40    NotConfigured,
41    /// The harness has no skill of this name.
42    NotFound { name: String },
43    /// The skill has no file at this path.
44    FileNotFound { name: String, path: String },
45    /// The name is not one skill name. The engine refuses these before asking
46    /// the provider.
47    InvalidName { name: String },
48    /// The path is not a relative path inside the skill. The engine refuses
49    /// these before asking the provider; a provider may refuse more.
50    InvalidPath { path: String },
51    /// The provider answered `load(name)` with a skill of another name. The
52    /// engine refuses it: the policy decided on `name`.
53    WrongSkill { name: String },
54    /// Reading failed for another reason. `message` is a fixed classification
55    /// chosen by the provider.
56    Failed { message: String },
57}
58
59impl std::fmt::Display for SkillError {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        match self {
62            Self::NotConfigured => write!(
63                f,
64                "no skill provider is configured: this harness does not offer skills"
65            ),
66            Self::NotFound { name } => write!(
67                f,
68                "no skill named `{}`; call `list()` for the skills you may load",
69                truncated(name)
70            ),
71            Self::FileNotFound { name, path } => write!(
72                f,
73                "skill `{}` has no file `{}`",
74                truncated(name),
75                truncated(path)
76            ),
77            Self::InvalidName { name } => write!(
78                f,
79                "`{}` is not a skill name: use a name as `list()` returns it — one segment of \
80                 at most 4096 bytes, not empty, `.` or `..`, and without `/`, `\\` or NUL",
81                truncated(name)
82            ),
83            Self::InvalidPath { path } => write!(
84                f,
85                "`{}` is not a path inside a skill: use a relative path of at most 4096 bytes \
86                 with `/` separators, no empty, `.` or `..` segment, and no `\\`, `:` or NUL",
87                truncated(path)
88            ),
89            Self::WrongSkill { name } => write!(
90                f,
91                "the harness answered `{}` with a different skill",
92                truncated(name)
93            ),
94            Self::Failed { message } => write!(f, "{message}"),
95        }
96    }
97}
98
99impl std::error::Error for SkillError {}
100
101/// The harness side of `submilli:skills`.
102pub trait SkillProvider: Send + Sync {
103    /// Every skill the harness offers, each under a name [`Self::load`] accepts.
104    /// The engine removes the ones the policy denies to the caller, and any whose
105    /// name it would refuse, before the program sees the list.
106    fn list<'a>(
107        &'a self,
108    ) -> Pin<Box<dyn Future<Output = Result<Vec<SkillInfo>, SkillError>> + Send + 'a>>;
109
110    /// The skill named `name`, whose returned `name` must be exactly `name`.
111    ///
112    /// `name` comes from the program and the policy decided on it as written, so
113    /// match it byte for byte: no case folding, Unicode normalization or path
114    /// canonicalization, or a policy that denies `secrets` would let `Secrets`
115    /// through. The engine passes only one segment of at most 4096 bytes that is
116    /// not empty, `.` or `..` and has no `/`, `\` or NUL; it may contain `:`, so
117    /// never use it as a path.
118    fn load<'a>(
119        &'a self,
120        name: &'a str,
121    ) -> Pin<Box<dyn Future<Output = Result<Skill, SkillError>> + Send + 'a>>;
122
123    /// The text of the file at `path` inside skill `name`. `name` is checked and
124    /// must be matched as for [`Self::load`]. `path` is relative, uses `/`
125    /// separators, has no empty, `.` or `..` segment and no `\`, `:` or NUL, and
126    /// is at most 4096 bytes. The engine does not decode it: a provider that
127    /// percent-decodes must check the decoded path again.
128    fn read_file<'a>(
129        &'a self,
130        name: &'a str,
131        path: &'a str,
132    ) -> Pin<Box<dyn Future<Output = Result<String, SkillError>> + Send + 'a>>;
133}