Skip to main content

sim_lib_skill/
registry.rs

1use std::{
2    collections::BTreeMap,
3    sync::{Arc, Mutex},
4};
5
6use sim_citizen_derive::non_citizen;
7use sim_kernel::{Cx, Error, Object, ObjectCompat, Result, Symbol, Value};
8
9#[cfg(feature = "openai")]
10use crate::SkillRole;
11#[cfg(any(feature = "cache", feature = "cassette"))]
12use crate::record::SkillAuditEntry;
13use crate::{SkillCallable, SkillCard, SkillTransport, SkillTransportValue};
14
15/// Live registry of skill transports and bound cards.
16///
17/// The registry owns the installed [`SkillTransport`]s and the [`SkillCard`]s
18/// bound to them, and (with the `cache`/`cassette` features) the audit log of
19/// skill calls. It is a cheaply clonable handle over shared state, so clones
20/// observe the same registry. It is a live handle rather than a serializable
21/// value; cards are projected through the `skill/Card` descriptor.
22#[derive(Clone, Default)]
23#[non_citizen(
24    reason = "live skill registry handle; cards use skill/Card descriptor",
25    kind = "handle",
26    descriptor = "skill/Card"
27)]
28pub struct SkillRegistry {
29    state: Arc<Mutex<SkillRegistryState>>,
30}
31
32#[derive(Default)]
33struct SkillRegistryState {
34    transports: BTreeMap<String, Arc<dyn SkillTransport>>,
35    cards: BTreeMap<String, SkillCard>,
36    #[cfg(any(feature = "cache", feature = "cassette"))]
37    audit: Vec<SkillAuditEntry>,
38}
39
40impl SkillRegistry {
41    /// Installs `transport`, keyed by its id, replacing any prior transport
42    /// with the same id.
43    pub fn install_transport(&self, transport: Arc<dyn SkillTransport>) -> Result<()> {
44        self.state
45            .lock()
46            .map_err(|_| Error::PoisonedLock("skill registry"))?
47            .transports
48            .insert(transport.id().to_owned(), transport);
49        Ok(())
50    }
51
52    /// Binds `card` to its installed transport and registers it as a callable.
53    ///
54    /// Resolves the card's transport, builds a [`SkillCallable`], registers it
55    /// under the card's symbol, publishes its browse claims, and stores the
56    /// card. Errors if the card's transport is not installed. Returns the
57    /// registered callable value.
58    pub fn bind_card(&self, cx: &mut Cx, card: SkillCard) -> Result<Value> {
59        let transport = {
60            let state = self
61                .state
62                .lock()
63                .map_err(|_| Error::PoisonedLock("skill registry"))?;
64            state
65                .transports
66                .get(&card.transport_id)
67                .cloned()
68                .ok_or_else(|| {
69                    Error::Eval(format!("missing skill transport {}", card.transport_id))
70                })?
71        };
72        #[cfg(any(feature = "cache", feature = "cassette"))]
73        let skill_callable = SkillCallable::new_bound(card.clone(), transport, self.clone());
74        #[cfg(not(any(feature = "cache", feature = "cassette")))]
75        let skill_callable = SkillCallable::new(card.clone(), transport);
76        let callable = cx.factory().opaque(Arc::new(skill_callable))?;
77        cx.registry_mut()
78            .register_function_value(card.symbol.clone(), callable.clone())?;
79        for alias in &card.aliases {
80            if alias != &card.symbol {
81                cx.registry_mut()
82                    .register_function_value(alias.clone(), callable.clone())?;
83            }
84        }
85        #[cfg(feature = "openai")]
86        if card.roles.contains(&SkillRole::Tool) {
87            let alias = openai_tool_alias(&card.symbol);
88            if alias != card.symbol && !card.aliases.iter().any(|existing| existing == &alias) {
89                cx.registry_mut()
90                    .register_function_value(alias, callable.clone())?;
91            }
92        }
93        crate::browse::publish_card_claims(cx, &card)?;
94        self.state
95            .lock()
96            .map_err(|_| Error::PoisonedLock("skill registry"))?
97            .cards
98            .insert(card.id.clone(), card);
99        Ok(callable)
100    }
101
102    /// Returns all bound cards.
103    pub fn cards(&self) -> Result<Vec<SkillCard>> {
104        Ok(self
105            .state
106            .lock()
107            .map_err(|_| Error::PoisonedLock("skill registry"))?
108            .cards
109            .values()
110            .cloned()
111            .collect())
112    }
113
114    /// Looks up a bound card by its `id`.
115    pub fn card_by_id(&self, id: &str) -> Result<Option<SkillCard>> {
116        Ok(self
117            .state
118            .lock()
119            .map_err(|_| Error::PoisonedLock("skill registry"))?
120            .cards
121            .get(id)
122            .cloned())
123    }
124
125    /// Looks up a bound card by its registered `symbol`.
126    pub fn card_by_symbol(&self, symbol: &Symbol) -> Result<Option<SkillCard>> {
127        Ok(self
128            .state
129            .lock()
130            .map_err(|_| Error::PoisonedLock("skill registry"))?
131            .cards
132            .values()
133            .find(|card| card_matches_symbol(card, symbol))
134            .cloned())
135    }
136
137    #[cfg(any(feature = "cache", feature = "cassette"))]
138    pub(crate) fn record_audit(&self, entry: SkillAuditEntry) -> Result<()> {
139        self.state
140            .lock()
141            .map_err(|_| Error::PoisonedLock("skill registry"))?
142            .audit
143            .push(entry);
144        Ok(())
145    }
146
147    #[cfg(any(feature = "cache", feature = "cassette"))]
148    pub(crate) fn audit_values(&self, cx: &mut Cx) -> Result<Value> {
149        let entries = self
150            .state
151            .lock()
152            .map_err(|_| Error::PoisonedLock("skill registry"))?
153            .audit
154            .clone();
155        let values = entries
156            .iter()
157            .map(|entry| entry.value(cx))
158            .collect::<Result<Vec<_>>>()?;
159        cx.factory().list(values)
160    }
161}
162
163impl Object for SkillRegistry {
164    fn display(&self, _cx: &mut Cx) -> Result<String> {
165        Ok("#<skill-registry>".to_owned())
166    }
167
168    fn as_any(&self) -> &dyn std::any::Any {
169        self
170    }
171}
172
173impl ObjectCompat for SkillRegistry {
174    fn as_table(&self, cx: &mut Cx) -> Result<Value> {
175        cx.factory().table(vec![
176            (
177                Symbol::new("kind"),
178                cx.factory().symbol(Symbol::new("skill/registry"))?,
179            ),
180            (
181                Symbol::new("cards"),
182                cx.factory().string(self.cards()?.len().to_string())?,
183            ),
184        ])
185    }
186}
187
188/// Returns the symbol the [`SkillRegistry`] value is bound under.
189pub fn skill_registry_symbol() -> Symbol {
190    Symbol::qualified("skill", "registry")
191}
192
193/// Resolves the [`SkillRegistry`] installed in `cx`.
194///
195/// Errors if the registry value is missing or is not a registry.
196pub fn skill_registry(cx: &mut Cx) -> Result<SkillRegistry> {
197    let value = cx.resolve_value(&skill_registry_symbol())?;
198    value
199        .object()
200        .downcast_ref::<SkillRegistry>()
201        .cloned()
202        .ok_or(Error::TypeMismatch {
203            expected: "skill registry",
204            found: "non-registry",
205        })
206}
207
208pub(crate) fn transport_from_value(value: &Value) -> Result<Arc<dyn SkillTransport>> {
209    value
210        .object()
211        .downcast_ref::<SkillTransportValue>()
212        .map(SkillTransportValue::transport)
213        .ok_or(Error::TypeMismatch {
214            expected: "skill transport",
215            found: "non-transport",
216        })
217}
218
219pub(crate) fn card_from_value(cx: &mut Cx, value: &Value) -> Result<SkillCard> {
220    if let Some(card) = value.object().downcast_ref::<SkillCard>() {
221        return Ok(card.clone());
222    }
223    let expr = value.object().as_expr(cx)?;
224    SkillCard::from_expr(&expr)
225}
226
227fn card_matches_symbol(card: &SkillCard, symbol: &Symbol) -> bool {
228    &card.symbol == symbol || card.aliases.iter().any(|alias| alias == symbol) || {
229        #[cfg(feature = "openai")]
230        {
231            card.roles.contains(&SkillRole::Tool) && openai_tool_alias(&card.symbol) == *symbol
232        }
233        #[cfg(not(feature = "openai"))]
234        {
235            false
236        }
237    }
238}
239
240#[cfg(feature = "openai")]
241fn openai_tool_alias(symbol: &Symbol) -> Symbol {
242    let name = sim_lib_surface_card::external_name(
243        symbol,
244        sim_lib_surface_card::ExternalNamePolicy::OpenAiTool,
245    );
246    let name = if name.is_empty() {
247        "skill".to_owned()
248    } else {
249        name
250    };
251    openai_name_to_symbol(&name)
252}
253
254#[cfg(feature = "openai")]
255fn openai_name_to_symbol(name: &str) -> Symbol {
256    if let Some((namespace, local)) = name.split_once('_') {
257        Symbol::qualified(namespace.replace('_', "-"), local.replace('_', "-"))
258    } else {
259        Symbol::new(name.replace('_', "-"))
260    }
261}