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. Read its bundled files through its `readFile(path)` method,
27/// never through `submilli:fs`: skills are not mounted on the virtual filesystem.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct Skill {
30    pub name: String,
31    pub description: Option<String>,
32    /// The skill's instructions.
33    pub content: String,
34    /// Bundled file names relative to this skill, accepted by `read_file`.
35    /// These are not filesystem paths; programs use `skill.readFile(path)`.
36    /// The engine omits invalid paths without limiting the number of files.
37    #[serde(default)]
38    pub files: Vec<String>,
39}
40
41/// Why a skill or one of its files could not be read. Every message is safe to
42/// show the program.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub enum SkillError {
45    /// No [`SkillProvider`] is wired into the runtime.
46    NotConfigured,
47    /// The harness has no skill of this name.
48    NotFound { name: String },
49    /// The skill has no file at this path.
50    FileNotFound { name: String, path: String },
51    /// The name is not one skill name. The engine refuses these before asking
52    /// the provider.
53    InvalidName { name: String },
54    /// The path is not a relative path inside the skill. The engine refuses
55    /// these before asking the provider; a provider may refuse more.
56    InvalidPath { path: String },
57    /// The provider answered `load(name)` with a skill of another name. The
58    /// engine refuses it: the policy decided on `name`.
59    WrongSkill { name: String },
60    /// Reading failed for another reason. `message` is a fixed classification
61    /// chosen by the provider.
62    Failed { message: String },
63}
64
65impl std::fmt::Display for SkillError {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        match self {
68            Self::NotConfigured => write!(
69                f,
70                "no skill provider is configured: this harness does not offer skills"
71            ),
72            Self::NotFound { name } => write!(
73                f,
74                "no skill named `{}`; call `list()` for the skills you may load",
75                truncated(name)
76            ),
77            Self::FileNotFound { name, path } => write!(
78                f,
79                "skill `{}` has no file `{}`",
80                truncated(name),
81                truncated(path)
82            ),
83            Self::InvalidName { name } => write!(
84                f,
85                "`{}` is not a skill name: use a name as `list()` returns it — one segment of \
86                 at most 4096 bytes, not empty, `.` or `..`, and without `/`, `\\` or NUL",
87                truncated(name)
88            ),
89            Self::InvalidPath { path } => write!(
90                f,
91                "`{}` is not a path inside a skill: use a relative path of at most 4096 bytes \
92                 with `/` separators, no empty, `.` or `..` segment, and no `\\`, `:` or NUL",
93                truncated(path)
94            ),
95            Self::WrongSkill { name } => write!(
96                f,
97                "the harness answered `{}` with a different skill",
98                truncated(name)
99            ),
100            Self::Failed { message } => write!(f, "{message}"),
101        }
102    }
103}
104
105impl std::error::Error for SkillError {}
106
107/// The harness side of `submilli:skills`.
108pub trait SkillProvider: Send + Sync {
109    /// Every skill the harness offers, each under a name [`Self::load`] accepts.
110    /// The engine removes the ones the policy denies to the caller, and any whose
111    /// name it would refuse, before the program sees the list.
112    fn list<'a>(
113        &'a self,
114    ) -> Pin<Box<dyn Future<Output = Result<Vec<SkillInfo>, SkillError>> + Send + 'a>>;
115
116    /// The skill named `name`, whose returned `name` must be exactly `name`.
117    ///
118    /// `name` comes from the program and the policy decided on it as written, so
119    /// match it byte for byte: no case folding, Unicode normalization or path
120    /// canonicalization, or a policy that denies `secrets` would let `Secrets`
121    /// through. The engine passes only one segment of at most 4096 bytes that is
122    /// not empty, `.` or `..` and has no `/`, `\` or NUL; it may contain `:`, so
123    /// never use it as a path.
124    /// Return bundled file names in `files` without reading their contents. Each
125    /// must be accepted by `read_file(name, path)`. Return the complete listing;
126    /// the engine removes invalid paths but does not cap the number of files.
127    fn load<'a>(
128        &'a self,
129        name: &'a str,
130    ) -> Pin<Box<dyn Future<Output = Result<Skill, SkillError>> + Send + 'a>>;
131
132    /// The text of the file at `path` inside skill `name`. `name` is checked and
133    /// must be matched as for [`Self::load`]. `path` is relative, uses `/`
134    /// separators, has no empty, `.` or `..` segment and no `\`, `:` or NUL, and
135    /// is at most 4096 bytes. The engine does not decode it: a provider that
136    /// percent-decodes must check the decoded path again.
137    fn read_file<'a>(
138        &'a self,
139        name: &'a str,
140        path: &'a str,
141    ) -> Pin<Box<dyn Future<Output = Result<String, SkillError>> + Send + 'a>>;
142}