Skip to main content

sim_lib_skill/
card.rs

1use sim_citizen_derive::non_citizen;
2use sim_kernel::{CapabilityName, Cx, Expr, Object, ObjectCompat, Result, ShapeRef, Symbol, Value};
3
4/// Role a skill plays for an agent.
5///
6/// A skill may carry more than one role; the role set drives how the skill is
7/// presented to tool, model, and resource surfaces.
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub enum SkillRole {
10    /// Callable tool the agent can invoke.
11    Tool,
12    /// Language or inference model.
13    Model,
14    /// Readable resource exposed to the agent.
15    Resource,
16    /// Reusable prompt template.
17    Prompt,
18    /// Memory store the agent can read from or write to.
19    Memory,
20    /// Retriever that fetches relevant context.
21    Retriever,
22    /// Judge that scores or evaluates candidate outputs.
23    Judge,
24    /// Router that dispatches to other skills.
25    Router,
26}
27
28impl SkillRole {
29    /// Returns the canonical symbol naming this role.
30    pub fn as_symbol(&self) -> Symbol {
31        Symbol::new(match self {
32            Self::Tool => "tool",
33            Self::Model => "model",
34            Self::Resource => "resource",
35            Self::Prompt => "prompt",
36            Self::Memory => "memory",
37            Self::Retriever => "retriever",
38            Self::Judge => "judge",
39            Self::Router => "router",
40        })
41    }
42}
43
44/// How much of a skill's raw payload may leave the local boundary.
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub enum SkillPrivacyPolicy {
47    /// Only metadata may be exposed; raw inputs and outputs stay private.
48    MetadataOnly,
49    /// Raw payloads must not be recorded or forwarded.
50    NoRaw,
51    /// Raw payloads may be used locally but never leave the host.
52    LocalOnly,
53    /// Raw payloads may be exposed and forwarded.
54    AllowRaw,
55}
56
57impl SkillPrivacyPolicy {
58    /// Returns the canonical symbol naming this privacy policy.
59    pub fn as_symbol(&self) -> Symbol {
60        Symbol::new(match self {
61            Self::MetadataOnly => "metadata-only",
62            Self::NoRaw => "no-raw",
63            Self::LocalOnly => "local-only",
64            Self::AllowRaw => "allow-raw",
65        })
66    }
67}
68
69/// Caching behavior for a skill's results.
70///
71/// Caching only applies to skills marked idempotent (see
72/// [`SkillPolicy::idempotent`]).
73#[derive(Clone, Debug, PartialEq, Eq)]
74pub enum SkillCacheMode {
75    /// No caching; every call reaches the transport.
76    Disabled,
77    /// Read from the cache on a hit, otherwise call and store the result.
78    ReadThrough,
79    /// Read from the cache but never store new results.
80    ReadOnly,
81    /// Store results but never serve from the cache.
82    WriteOnly,
83    /// Bypass cached results and refresh the stored entry from a live call.
84    Refresh,
85}
86
87impl SkillCacheMode {
88    /// Returns the canonical symbol naming this cache mode.
89    pub fn as_symbol(&self) -> Symbol {
90        Symbol::new(match self {
91            Self::Disabled => "disabled",
92            Self::ReadThrough => "read-through",
93            Self::ReadOnly => "read-only",
94            Self::WriteOnly => "write-only",
95            Self::Refresh => "refresh",
96        })
97    }
98}
99
100/// Cassette (record/replay) behavior for a skill's calls.
101///
102/// Cassettes capture deterministic recordings of skill calls for replay in
103/// tests and offline runs.
104#[derive(Clone, Debug, PartialEq, Eq)]
105pub enum SkillCassetteMode {
106    /// No recording or replay.
107    Disabled,
108    /// Replay a recorded result on a hit, otherwise call and record it.
109    RecordReplay,
110    /// Replay only; a missing recording is an error.
111    ReplayOnly,
112    /// Record live calls without replaying existing recordings.
113    RecordOnly,
114}
115
116impl SkillCassetteMode {
117    /// Returns the canonical symbol naming this cassette mode.
118    pub fn as_symbol(&self) -> Symbol {
119        Symbol::new(match self {
120            Self::Disabled => "disabled",
121            Self::RecordReplay => "record-replay",
122            Self::ReplayOnly => "replay-only",
123            Self::RecordOnly => "record-only",
124        })
125    }
126}
127
128/// Privacy, caching, and recording policy attached to a [`SkillCard`].
129#[derive(Clone, Debug, PartialEq, Eq)]
130pub struct SkillPolicy {
131    /// How much of the raw payload may leave the local boundary.
132    pub privacy: SkillPrivacyPolicy,
133    /// Caching behavior for results (effective only when idempotent).
134    pub cache: SkillCacheMode,
135    /// Cassette record/replay behavior for calls.
136    pub cassette: SkillCassetteMode,
137    /// Whether repeated calls with the same arguments yield the same result,
138    /// which is what makes caching sound.
139    pub idempotent: bool,
140    /// Optional explicit key for deriving the cache/cassette identity for a call
141    /// instead of the default argument-derived key.
142    pub semantic_key: Option<String>,
143}
144
145impl Default for SkillPolicy {
146    fn default() -> Self {
147        Self {
148            privacy: SkillPrivacyPolicy::NoRaw,
149            cache: SkillCacheMode::Disabled,
150            cassette: SkillCassetteMode::Disabled,
151            idempotent: false,
152            semantic_key: None,
153        }
154    }
155}
156
157/// Full runtime description of a single skill.
158///
159/// A card carries the skill's identity and symbol, its input and output shape
160/// contracts, the roles it plays, the capabilities required to call it, its
161/// [`SkillPolicy`], and the transport coordinates (id, kind, and operation)
162/// that dispatch a call. It is a shape-bearing runtime handle; its
163/// serializable projection is the [`SkillCardDescriptor`] (`skill/Card`).
164///
165/// [`SkillCardDescriptor`]: crate::SkillCardDescriptor
166#[derive(Clone)]
167#[non_citizen(
168    reason = "shape-bearing runtime skill card; serializable projection is skill/Card descriptor",
169    kind = "handle",
170    descriptor = "skill/Card"
171)]
172pub struct SkillCard {
173    /// Stable string identifier for the skill.
174    pub id: String,
175    /// Symbol the skill is registered and called under.
176    pub symbol: Symbol,
177    /// Additional symbols that also resolve to this skill.
178    pub aliases: Vec<Symbol>,
179    /// Symbol naming where the card came from (for example `fixture`).
180    pub origin: Symbol,
181    /// Human-readable title.
182    pub title: String,
183    /// Human-readable description of what the skill does.
184    pub description: String,
185    /// Shape contract the call arguments must satisfy.
186    pub input_shape: ShapeRef,
187    /// Shape contract the call result must satisfy.
188    pub output_shape: ShapeRef,
189    /// Roles this skill plays for an agent.
190    pub roles: Vec<SkillRole>,
191    /// Capabilities a caller must hold to invoke the skill.
192    pub capabilities: Vec<CapabilityName>,
193    /// Privacy, caching, and recording policy.
194    pub policy: SkillPolicy,
195    /// Identifier of the transport that runs the skill.
196    pub transport_id: String,
197    /// Kind of the transport (for example `fixture`, `mcp`, `http`).
198    pub transport_kind: String,
199    /// Operation name the transport dispatches for this skill.
200    pub operation: String,
201}
202
203/// Inputs for building a fixture [`SkillCard`] via [`SkillCard::fixture`].
204pub struct FixtureSkillSpec {
205    /// Stable string identifier for the skill.
206    pub id: String,
207    /// Symbol the skill is registered and called under.
208    pub symbol: Symbol,
209    /// Human-readable title.
210    pub title: String,
211    /// Human-readable description.
212    pub description: String,
213    /// Shape contract for the call arguments.
214    pub input_shape: ShapeRef,
215    /// Shape contract for the call result.
216    pub output_shape: ShapeRef,
217    /// Identifier of the fixture transport that runs the skill.
218    pub transport_id: String,
219    /// Operation name the fixture transport dispatches.
220    pub operation: String,
221}
222
223impl SkillCard {
224    /// Builds a fixture skill card from `spec`.
225    ///
226    /// The card is given the `fixture` origin, the [`SkillRole::Tool`] role, a
227    /// default [`SkillPolicy`], and a single skill-specific call capability.
228    pub fn fixture(spec: FixtureSkillSpec) -> Self {
229        let id = spec.id;
230        Self {
231            capabilities: vec![crate::skill_specific_call_capability(&id)],
232            id,
233            symbol: spec.symbol,
234            aliases: Vec::new(),
235            origin: Symbol::new("fixture"),
236            title: spec.title,
237            description: spec.description,
238            input_shape: spec.input_shape,
239            output_shape: spec.output_shape,
240            roles: vec![SkillRole::Tool],
241            policy: SkillPolicy::default(),
242            transport_id: spec.transport_id,
243            transport_kind: "fixture".to_owned(),
244            operation: spec.operation,
245        }
246    }
247
248    /// Returns the card with `capability` appended to its required capabilities.
249    pub fn with_capability(mut self, capability: CapabilityName) -> Self {
250        self.capabilities.push(capability);
251        self
252    }
253
254    /// Returns the card with `role` added if it is not already present.
255    pub fn with_role(mut self, role: SkillRole) -> Self {
256        if !self.roles.contains(&role) {
257            self.roles.push(role);
258        }
259        self
260    }
261
262    /// Returns the card with its policy replaced by `policy`.
263    pub fn with_policy(mut self, policy: SkillPolicy) -> Self {
264        self.policy = policy;
265        self
266    }
267
268    /// Returns the card with its cache mode set to `cache`.
269    pub fn with_cache_mode(mut self, cache: SkillCacheMode) -> Self {
270        self.policy.cache = cache;
271        self
272    }
273
274    /// Returns the card with its cassette mode set to `cassette`.
275    pub fn with_cassette_mode(mut self, cassette: SkillCassetteMode) -> Self {
276        self.policy.cassette = cassette;
277        self
278    }
279
280    /// Returns the card with its idempotency flag set to `idempotent`.
281    pub fn with_idempotent(mut self, idempotent: bool) -> Self {
282        self.policy.idempotent = idempotent;
283        self
284    }
285
286    /// Returns the card with its semantic key set to `semantic_key`.
287    pub fn with_semantic_key(mut self, semantic_key: impl Into<String>) -> Self {
288        self.policy.semantic_key = Some(semantic_key.into());
289        self
290    }
291
292    /// Returns the card with its privacy policy set to `privacy`.
293    pub fn with_privacy(mut self, privacy: SkillPrivacyPolicy) -> Self {
294        self.policy.privacy = privacy;
295        self
296    }
297
298    /// Wraps the card in an opaque runtime [`Value`].
299    pub fn value(&self, cx: &mut Cx) -> Result<Value> {
300        cx.factory().opaque(std::sync::Arc::new(self.clone()))
301    }
302
303    /// Projects the card into a table [`Value`] describing its fields.
304    pub fn table_value(&self, cx: &mut Cx) -> Result<Value> {
305        let aliases = cx.factory().list(
306            self.aliases
307                .iter()
308                .map(|alias| cx.factory().symbol(alias.clone()))
309                .collect::<Result<Vec<_>>>()?,
310        )?;
311        let roles = cx.factory().list(
312            self.roles
313                .iter()
314                .map(|role| cx.factory().symbol(role.as_symbol()))
315                .collect::<Result<Vec<_>>>()?,
316        )?;
317        let capabilities = cx.factory().list(
318            self.capabilities
319                .iter()
320                .map(|capability| cx.factory().string(capability.as_str().to_owned()))
321                .collect::<Result<Vec<_>>>()?,
322        )?;
323        let transport = cx.factory().table(vec![
324            (
325                Symbol::new("id"),
326                cx.factory().string(self.transport_id.clone())?,
327            ),
328            (
329                Symbol::new("kind"),
330                cx.factory()
331                    .symbol(Symbol::new(self.transport_kind.clone()))?,
332            ),
333            (
334                Symbol::new("operation"),
335                cx.factory().string(self.operation.clone())?,
336            ),
337        ])?;
338        let mut policy = vec![
339            (
340                Symbol::new("privacy"),
341                cx.factory().symbol(self.policy.privacy.as_symbol())?,
342            ),
343            (
344                Symbol::new("cache"),
345                cx.factory().symbol(self.policy.cache.as_symbol())?,
346            ),
347            (
348                Symbol::new("cassette"),
349                cx.factory().symbol(self.policy.cassette.as_symbol())?,
350            ),
351            (
352                Symbol::new("idempotent"),
353                cx.factory().bool(self.policy.idempotent)?,
354            ),
355        ];
356        if let Some(semantic_key) = &self.policy.semantic_key {
357            policy.push((
358                Symbol::new("semantic-key"),
359                cx.factory().string(semantic_key.clone())?,
360            ));
361        }
362        let policy = cx.factory().table(policy)?;
363        cx.factory().table(vec![
364            (
365                Symbol::new("kind"),
366                cx.factory().symbol(Symbol::qualified("skill", "card"))?,
367            ),
368            (Symbol::new("id"), cx.factory().string(self.id.clone())?),
369            (
370                Symbol::new("symbol"),
371                cx.factory().symbol(self.symbol.clone())?,
372            ),
373            (Symbol::new("aliases"), aliases),
374            (
375                Symbol::new("origin"),
376                cx.factory().symbol(self.origin.clone())?,
377            ),
378            (
379                Symbol::new("title"),
380                cx.factory().string(self.title.clone())?,
381            ),
382            (
383                Symbol::new("description"),
384                cx.factory().string(self.description.clone())?,
385            ),
386            (Symbol::new("input-shape"), self.input_shape.clone()),
387            (Symbol::new("output-shape"), self.output_shape.clone()),
388            (Symbol::new("roles"), roles),
389            (Symbol::new("capabilities"), capabilities),
390            (Symbol::new("policy"), policy),
391            (Symbol::new("transport"), transport),
392        ])
393    }
394}
395
396impl Object for SkillCard {
397    fn display(&self, _cx: &mut Cx) -> Result<String> {
398        Ok(format!("#<skill-card {}>", self.id))
399    }
400
401    fn as_any(&self) -> &dyn std::any::Any {
402        self
403    }
404}
405
406impl ObjectCompat for SkillCard {
407    fn as_expr(&self, cx: &mut Cx) -> Result<Expr> {
408        self.to_expr(cx)
409    }
410
411    fn as_table(&self, cx: &mut Cx) -> Result<Value> {
412        self.table_value(cx)
413    }
414}