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}