Skip to main content

nexus_sdk/types/
tool.rs

1//! Nexus helpers for the generated on chain [`crate::move_bindings::tool::tool_registry::Tool`]
2//! representation.
3//!
4//! The persisted object shape is generated from Move. This module must not
5//! duplicate that shape; it only adds SDK projections that are not part of the
6//! ABI itself.
7
8pub use tool_registry::{Tool as ToolAnchor, ToolInnerV1, ToolRef};
9/// Complete supported view of one [`ToolAnchor`].
10///
11/// [`ToolInnerV1`] is stored below the Tool anchor. This view pairs the stable
12/// object ID with that current logical state.
13#[derive(Clone, Debug, Eq, PartialEq)]
14pub struct ToolState {
15    /// Stable Tool object ID.
16    pub object_id: sui::types::Address,
17    /// Current supported Tool inner layout.
18    pub inner: ToolInnerV1,
19}
20
21/// Current supported [`ToolState`] view.
22pub type Tool = ToolState;
23use {
24    crate::{
25        move_bindings::{move_std::ascii, tool::tool_registry},
26        sui,
27        ToolFqn,
28    },
29    anyhow::{anyhow, bail, Context as _},
30    chrono::{DateTime, Utc},
31};
32
33/// Registry mode derived from an onchain Tool `execute` signature.
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub enum OnchainToolMode {
36    /// `execute` begins with `UIDRequirements` and `OnchainToolResult`.
37    Standard,
38    /// `execute` begins with an Agent vertex authorization proof.
39    WorkflowAuthorization,
40}
41
42impl ToolAnchor {
43    pub fn object_id(&self) -> sui::types::Address {
44        self.id.address()
45    }
46}
47
48impl ToolState {
49    /// Creates a complete supported Tool view.
50    pub fn new(object_id: sui::types::Address, inner: ToolInnerV1) -> Self {
51        Self { object_id, inner }
52    }
53
54    /// Derive a [`ToolAnchor`] object ID from the ToolRegistry ID and tool FQN.
55    pub fn derive_id(
56        registry_id: sui::types::Address,
57        fqn: &ToolFqn,
58    ) -> anyhow::Result<sui::types::Address> {
59        crate::move_bindings::derive_tool_id(registry_id, fqn)
60    }
61
62    pub fn registry_id(&self) -> sui::types::Address {
63        self.inner.registry.address()
64    }
65
66    pub fn fqn_string(&self) -> anyhow::Result<String> {
67        ascii_string(&self.inner.fqn).context("Tool FQN is not UTF-8")
68    }
69
70    pub fn parsed_fqn(&self) -> anyhow::Result<ToolFqn> {
71        let value = self.fqn_string()?;
72        value
73            .parse()
74            .map_err(|error| anyhow!("Tool FQN '{value}' did not parse: {error}"))
75    }
76
77    pub fn reference(&self) -> &ToolRef {
78        &self.inner.r#ref
79    }
80
81    pub fn description_string(&self) -> anyhow::Result<String> {
82        std::str::from_utf8(&self.inner.description)
83            .map(str::to_owned)
84            .context("Tool description is not UTF-8")
85    }
86
87    pub fn registered_at_datetime(&self) -> anyhow::Result<DateTime<Utc>> {
88        timestamp_millis_to_datetime(self.inner.registered_at_ms, "registered_at_ms")
89    }
90
91    pub fn unregistered_at_datetime(&self) -> anyhow::Result<Option<DateTime<Utc>>> {
92        self.unregistered_at_millis()?
93            .map(|millis| timestamp_millis_to_datetime(millis, "closed_at_ms"))
94            .transpose()
95    }
96
97    pub fn unregistered_at_millis(&self) -> anyhow::Result<Option<u64>> {
98        match self.inner.unregistered_at_ms.vec.as_slice() {
99            [] => Ok(None),
100            [millis] => Ok(Some(*millis)),
101            values => bail!(
102                "Tool unregistered_at_ms is not a valid Move option: {} values",
103                values.len()
104            ),
105        }
106    }
107}
108
109impl ToolRef {
110    pub fn http_url_string(&self) -> anyhow::Result<Option<String>> {
111        let Self::Http { url, .. } = self else {
112            return Ok(None);
113        };
114        bytes_string(url, "HTTP tool URL").map(Some)
115    }
116
117    pub fn sui_parts(
118        &self,
119    ) -> anyhow::Result<Option<(sui::types::Address, String, sui::types::Address)>> {
120        let Self::Sui {
121            package_address,
122            module_name,
123            tool_witness_id,
124            ..
125        } = self
126        else {
127            return Ok(None);
128        };
129
130        Ok(Some((
131            *package_address,
132            ascii_string(module_name).context("Sui tool module name is not UTF-8")?,
133            tool_witness_id.address(),
134        )))
135    }
136
137    pub fn display_string(&self) -> anyhow::Result<String> {
138        match self {
139            Self::Http { .. } => self
140                .http_url_string()?
141                .ok_or_else(|| anyhow!("expected HTTP tool reference")),
142            Self::Sui { .. } => {
143                let Some((package_address, module_name, tool_witness_id)) = self.sui_parts()?
144                else {
145                    unreachable!("matched Sui tool reference")
146                };
147                Ok(format!(
148                    "{package_address}::{module_name}@{tool_witness_id}"
149                ))
150            }
151        }
152    }
153}
154
155impl std::fmt::Display for ToolRef {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        match self.display_string() {
158            Ok(value) => f.write_str(&value),
159            Err(_) => f.write_str("<invalid tool reference>"),
160        }
161    }
162}
163
164fn ascii_string(value: &ascii::String) -> anyhow::Result<String> {
165    bytes_string(&value.bytes, "Move ASCII string")
166}
167
168fn bytes_string(value: &[u8], label: &str) -> anyhow::Result<String> {
169    std::str::from_utf8(value)
170        .map(str::to_owned)
171        .with_context(|| format!("{label} is not UTF-8"))
172}
173
174fn timestamp_millis_to_datetime(value: u64, field: &str) -> anyhow::Result<DateTime<Utc>> {
175    let millis = i64::try_from(value).with_context(|| format!("{field} exceeds i64::MAX"))?;
176    DateTime::<Utc>::from_timestamp_millis(millis)
177        .ok_or_else(|| anyhow!("{field} is outside chrono's timestamp range"))
178}
179
180#[cfg(test)]
181mod tests {
182    use {
183        super::*,
184        crate::{
185            fqn,
186            move_bindings::{move_std::option::Option as MoveOption, sui_framework},
187            test_utils::sui_mocks,
188        },
189    };
190
191    fn ascii(value: &str) -> ascii::String {
192        ascii::String::from(value)
193    }
194
195    fn fixture_tool(reference: ToolRef) -> ToolState {
196        let object_id = sui_mocks::mock_sui_address();
197        ToolState::new(
198            object_id,
199            ToolInnerV1::new(
200                crate::move_bindings::sui_framework::object::ID::new(sui_mocks::mock_sui_address()),
201                ascii("xyz.taluslabs.math.i64.add@1"),
202                reference,
203                b"A test tool".to_vec(),
204                crate::move_bindings::interface::meta_schema::MetaSchema::new(vec![], vec![]),
205                false,
206                sui_framework::balance::Balance {
207                    value: 0,
208                    phantom_t0: std::marker::PhantomData,
209                },
210                true,
211                0,
212                0,
213                MoveOption::from(None),
214            ),
215        )
216    }
217
218    #[test]
219    fn test_tool_derive_id() {
220        let registry_id = sui::types::Address::from_static(
221            "0x940f0dd81d4e4ae2cd476ff61ca5699e0d9356e1874d6c4ba3a5bdf28e67b9e9",
222        );
223
224        let fqn = fqn!("xyz.taluslabs.math.i64.add@1");
225        let expected_id = sui::types::Address::from_static(
226            "0x63152163bf12d54f38742656cba5d37a05e89d3ef5df7e9d22062e7bff0aed35",
227        );
228        assert_eq!(Tool::derive_id(registry_id, &fqn).unwrap(), expected_id);
229
230        let fqn = fqn!("xyz.taluslabs.math.i64.mul@1");
231        let expected_id = sui::types::Address::from_static(
232            "0xc841b225a7e79c76942f3df05f1fcf17c2b259626ed51cb84e562cb3403604da",
233        );
234        assert_eq!(Tool::derive_id(registry_id, &fqn).unwrap(), expected_id);
235    }
236
237    #[test]
238    fn generated_tool_parts_round_trip_and_preserve_meta_schema() {
239        let tool = fixture_tool(ToolRef::Http {
240            url: b"https://example.com/tool".to_vec(),
241        });
242
243        let inner_bytes = bcs::to_bytes(&tool.inner).expect("generated inner serializes as BCS");
244        let decoded = ToolState::new(
245            tool.object_id,
246            bcs::from_bytes(&inner_bytes).expect("generated inner decodes"),
247        );
248
249        assert_eq!(
250            decoded.fqn_string().unwrap(),
251            "xyz.taluslabs.math.i64.add@1"
252        );
253        assert_eq!(decoded.description_string().unwrap(), "A test tool");
254        assert_eq!(
255            decoded.registered_at_datetime().unwrap(),
256            DateTime::<Utc>::from_timestamp(0, 0).unwrap()
257        );
258        assert_eq!(decoded.inner.meta_schema, tool.inner.meta_schema);
259    }
260
261    #[test]
262    fn tool_ref_helpers_decode_http_and_sui() {
263        let http_ref = ToolRef::Http {
264            url: b"https://example.com/tool".to_vec(),
265        };
266        assert_eq!(
267            http_ref.http_url_string().unwrap().unwrap().as_str(),
268            "https://example.com/tool"
269        );
270        assert_eq!(http_ref.to_string(), "https://example.com/tool");
271
272        let package_address = sui::types::Address::from_static(
273            "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
274        );
275        let tool_witness_id = sui::types::Address::from_static(
276            "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
277        );
278        let sui_ref = ToolRef::Sui {
279            package_address,
280            module_name: ascii("my_tool_module"),
281            tool_witness_id: crate::move_bindings::sui_framework::object::ID::new(tool_witness_id),
282        };
283        let (package, module, witness) = sui_ref.sui_parts().unwrap().unwrap();
284        assert_eq!(package, package_address);
285        assert_eq!(module, "my_tool_module");
286        assert_eq!(witness, tool_witness_id);
287        assert_eq!(
288            sui_ref.to_string(),
289            format!("{package_address}::my_tool_module@{tool_witness_id}")
290        );
291    }
292}