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}