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 used to derive the cache/cassette identity for a
141    /// call 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/// used to 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)]
171pub struct SkillCard {
172    /// Stable string identifier for the skill.
173    pub id: String,
174    /// Symbol the skill is registered and called under.
175    pub symbol: Symbol,
176    /// Additional symbols that also resolve to this skill.
177    pub aliases: Vec<Symbol>,
178    /// Symbol naming where the card came from (for example `fixture`).
179    pub origin: Symbol,
180    /// Human-readable title.
181    pub title: String,
182    /// Human-readable description of what the skill does.
183    pub description: String,
184    /// Shape contract the call arguments must satisfy.
185    pub input_shape: ShapeRef,
186    /// Shape contract the call result must satisfy.
187    pub output_shape: ShapeRef,
188    /// Roles this skill plays for an agent.
189    pub roles: Vec<SkillRole>,
190    /// Capabilities a caller must hold to invoke the skill.
191    pub capabilities: Vec<CapabilityName>,
192    /// Privacy, caching, and recording policy.
193    pub policy: SkillPolicy,
194    /// Identifier of the transport that runs the skill.
195    pub transport_id: String,
196    /// Kind of the transport (for example `fixture`, `mcp`, `http`).
197    pub transport_kind: String,
198    /// Operation name the transport dispatches for this skill.
199    pub operation: String,
200}
201
202/// Inputs for building a fixture [`SkillCard`] via [`SkillCard::fixture`].
203pub struct FixtureSkillSpec {
204    /// Stable string identifier for the skill.
205    pub id: String,
206    /// Symbol the skill is registered and called under.
207    pub symbol: Symbol,
208    /// Human-readable title.
209    pub title: String,
210    /// Human-readable description.
211    pub description: String,
212    /// Shape contract for the call arguments.
213    pub input_shape: ShapeRef,
214    /// Shape contract for the call result.
215    pub output_shape: ShapeRef,
216    /// Identifier of the fixture transport that runs the skill.
217    pub transport_id: String,
218    /// Operation name the fixture transport dispatches.
219    pub operation: String,
220}
221
222impl SkillCard {
223    /// Builds a fixture skill card from `spec`.
224    ///
225    /// The card is given the `fixture` origin, the [`SkillRole::Tool`] role, a
226    /// default [`SkillPolicy`], and a single skill-specific call capability.
227    pub fn fixture(spec: FixtureSkillSpec) -> Self {
228        let id = spec.id;
229        Self {
230            capabilities: vec![crate::skill_specific_call_capability(&id)],
231            id,
232            symbol: spec.symbol,
233            aliases: Vec::new(),
234            origin: Symbol::new("fixture"),
235            title: spec.title,
236            description: spec.description,
237            input_shape: spec.input_shape,
238            output_shape: spec.output_shape,
239            roles: vec![SkillRole::Tool],
240            policy: SkillPolicy::default(),
241            transport_id: spec.transport_id,
242            transport_kind: "fixture".to_owned(),
243            operation: spec.operation,
244        }
245    }
246
247    /// Returns the card with `capability` appended to its required capabilities.
248    pub fn with_capability(mut self, capability: CapabilityName) -> Self {
249        self.capabilities.push(capability);
250        self
251    }
252
253    /// Returns the card with `role` added if it is not already present.
254    pub fn with_role(mut self, role: SkillRole) -> Self {
255        if !self.roles.contains(&role) {
256            self.roles.push(role);
257        }
258        self
259    }
260
261    /// Returns the card with its policy replaced by `policy`.
262    pub fn with_policy(mut self, policy: SkillPolicy) -> Self {
263        self.policy = policy;
264        self
265    }
266
267    /// Returns the card with its cache mode set to `cache`.
268    pub fn with_cache_mode(mut self, cache: SkillCacheMode) -> Self {
269        self.policy.cache = cache;
270        self
271    }
272
273    /// Returns the card with its cassette mode set to `cassette`.
274    pub fn with_cassette_mode(mut self, cassette: SkillCassetteMode) -> Self {
275        self.policy.cassette = cassette;
276        self
277    }
278
279    /// Returns the card with its idempotency flag set to `idempotent`.
280    pub fn with_idempotent(mut self, idempotent: bool) -> Self {
281        self.policy.idempotent = idempotent;
282        self
283    }
284
285    /// Returns the card with its semantic key set to `semantic_key`.
286    pub fn with_semantic_key(mut self, semantic_key: impl Into<String>) -> Self {
287        self.policy.semantic_key = Some(semantic_key.into());
288        self
289    }
290
291    /// Returns the card with its privacy policy set to `privacy`.
292    pub fn with_privacy(mut self, privacy: SkillPrivacyPolicy) -> Self {
293        self.policy.privacy = privacy;
294        self
295    }
296
297    /// Wraps the card in an opaque runtime [`Value`].
298    pub fn value(&self, cx: &mut Cx) -> Result<Value> {
299        cx.factory().opaque(std::sync::Arc::new(self.clone()))
300    }
301
302    /// Projects the card into a table [`Value`] describing its fields.
303    pub fn table_value(&self, cx: &mut Cx) -> Result<Value> {
304        let aliases = cx.factory().list(
305            self.aliases
306                .iter()
307                .map(|alias| cx.factory().symbol(alias.clone()))
308                .collect::<Result<Vec<_>>>()?,
309        )?;
310        let roles = cx.factory().list(
311            self.roles
312                .iter()
313                .map(|role| cx.factory().symbol(role.as_symbol()))
314                .collect::<Result<Vec<_>>>()?,
315        )?;
316        let capabilities = cx.factory().list(
317            self.capabilities
318                .iter()
319                .map(|capability| cx.factory().string(capability.as_str().to_owned()))
320                .collect::<Result<Vec<_>>>()?,
321        )?;
322        let transport = cx.factory().table(vec![
323            (
324                Symbol::new("id"),
325                cx.factory().string(self.transport_id.clone())?,
326            ),
327            (
328                Symbol::new("kind"),
329                cx.factory()
330                    .symbol(Symbol::new(self.transport_kind.clone()))?,
331            ),
332            (
333                Symbol::new("operation"),
334                cx.factory().string(self.operation.clone())?,
335            ),
336        ])?;
337        let mut policy = vec![
338            (
339                Symbol::new("privacy"),
340                cx.factory().symbol(self.policy.privacy.as_symbol())?,
341            ),
342            (
343                Symbol::new("cache"),
344                cx.factory().symbol(self.policy.cache.as_symbol())?,
345            ),
346            (
347                Symbol::new("cassette"),
348                cx.factory().symbol(self.policy.cassette.as_symbol())?,
349            ),
350            (
351                Symbol::new("idempotent"),
352                cx.factory().bool(self.policy.idempotent)?,
353            ),
354        ];
355        if let Some(semantic_key) = &self.policy.semantic_key {
356            policy.push((
357                Symbol::new("semantic-key"),
358                cx.factory().string(semantic_key.clone())?,
359            ));
360        }
361        let policy = cx.factory().table(policy)?;
362        cx.factory().table(vec![
363            (
364                Symbol::new("kind"),
365                cx.factory().symbol(Symbol::qualified("skill", "card"))?,
366            ),
367            (Symbol::new("id"), cx.factory().string(self.id.clone())?),
368            (
369                Symbol::new("symbol"),
370                cx.factory().symbol(self.symbol.clone())?,
371            ),
372            (Symbol::new("aliases"), aliases),
373            (
374                Symbol::new("origin"),
375                cx.factory().symbol(self.origin.clone())?,
376            ),
377            (
378                Symbol::new("title"),
379                cx.factory().string(self.title.clone())?,
380            ),
381            (
382                Symbol::new("description"),
383                cx.factory().string(self.description.clone())?,
384            ),
385            (Symbol::new("input-shape"), self.input_shape.clone()),
386            (Symbol::new("output-shape"), self.output_shape.clone()),
387            (Symbol::new("roles"), roles),
388            (Symbol::new("capabilities"), capabilities),
389            (Symbol::new("policy"), policy),
390            (Symbol::new("transport"), transport),
391        ])
392    }
393}
394
395impl Object for SkillCard {
396    fn display(&self, _cx: &mut Cx) -> Result<String> {
397        Ok(format!("#<skill-card {}>", self.id))
398    }
399
400    fn as_any(&self) -> &dyn std::any::Any {
401        self
402    }
403}
404
405impl ObjectCompat for SkillCard {
406    fn as_expr(&self, cx: &mut Cx) -> Result<Expr> {
407        self.to_expr(cx)
408    }
409
410    fn as_table(&self, cx: &mut Cx) -> Result<Value> {
411        self.table_value(cx)
412    }
413}