From 599e6160876827118725ad1a7ad88aeb76e72985 Mon Sep 17 00:00:00 2001 From: 0xZensh Date: Sun, 19 Jan 2025 12:35:08 +0800 Subject: [PATCH] feat: implement ICP tokens transfer tool --- Cargo.lock | 33 +++++ Cargo.toml | 4 +- anda_core/src/tool.rs | 12 +- anda_engine/Cargo.toml | 2 +- anda_engine/src/context/mod.rs | 152 ++++++++++++++++++++ anda_engine/src/extension/extractor.rs | 7 +- tools/anda_icp/Cargo.toml | 41 ++++++ tools/anda_icp/src/ledger.rs | 191 +++++++++++++++++++++++++ tools/anda_icp/src/lib.rs | 1 + 9 files changed, 433 insertions(+), 10 deletions(-) create mode 100644 tools/anda_icp/Cargo.toml create mode 100644 tools/anda_icp/src/ledger.rs create mode 100644 tools/anda_icp/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 2ded0b6..1b141d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -193,6 +193,39 @@ dependencies = [ "toml", ] +[[package]] +name = "anda_icp" +version = "0.1.0" +dependencies = [ + "anda_core", + "anda_engine", + "async-trait", + "bytes", + "candid", + "chrono", + "ciborium", + "dotenv", + "futures", + "futures-util", + "http 1.2.0", + "ic_cose", + "ic_cose_types", + "icrc-ledger-types", + "log", + "moka 0.12.10", + "object_store 0.10.2", + "rand", + "reqwest 0.12.12", + "schemars", + "serde", + "serde_bytes", + "serde_json", + "structured-logger", + "tokio", + "tokio-util", + "toml", +] + [[package]] name = "anda_lancedb" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index 97a0efc..19efee1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["anda_core", "anda_engine", "anda_lancedb", "agents/*"] +members = ["anda_core", "anda_engine", "anda_lancedb", "agents/*", "tools/*"] [workspace.package] description = "Anda is a framework for AI agent development, designed to build a highly composable, autonomous, and perpetually memorizing network of AI agents." @@ -17,6 +17,7 @@ edition = "2021" license = "MIT OR Apache-2.0" [workspace.dependencies] +async-trait = "0.1" axum = { version = "0.8", features = [ "http1", "http2", @@ -65,6 +66,7 @@ ed25519-consensus = "2.1" log = "0.4" chrono = "0.4" dotenv = "0.15" +schemars = { version = "0.8" } [profile.release] debug = false diff --git a/anda_core/src/tool.rs b/anda_core/src/tool.rs index 871e718..ecfae8a 100644 --- a/anda_core/src/tool.rs +++ b/anda_core/src/tool.rs @@ -12,6 +12,8 @@ pub trait Tool: Send + Sync where C: BaseContext + Send + Sync, { + /// A constant flag indicating whether the agent should continue processing the tool result + /// with completion model after execution const CONTINUE: bool; /// The arguments type of the tool. type Args: DeserializeOwned + Send; @@ -27,6 +29,9 @@ where /// - Can only contain: lowercase letters (a-z), digits (0-9), and underscores (_) fn name(&self) -> String; + /// Returns the tool's capabilities description in a short string + fn description(&self) -> String; + /// Provides the tool's definition including its parameters schema. /// /// # Returns @@ -74,12 +79,7 @@ where args: String, ) -> impl Future> + Send { async move { - let args: Self::Args = serde_json::from_str(&args) - .map_err(|err| format!("tool {}, invalid args: {}", self.name(), err))?; - let result = self - .call(ctx, args) - .await - .map_err(|err| format!("tool {}, call failed: {}", self.name(), err))?; + let result = self.call_string(ctx, args).await?; Ok((serde_json::to_string(&result)?, Self::CONTINUE)) } } diff --git a/anda_engine/Cargo.toml b/anda_engine/Cargo.toml index e0ba6d0..8d60eba 100644 --- a/anda_engine/Cargo.toml +++ b/anda_engine/Cargo.toml @@ -32,7 +32,7 @@ toml = { workspace = true } tokio = { workspace = true } log = { workspace = true } chrono = { workspace = true } -schemars = { version = "0.8" } +schemars = { workspace = true } [dev-dependencies] dotenv = { workspace = true } diff --git a/anda_engine/src/context/mod.rs b/anda_engine/src/context/mod.rs index 4e8998d..c974342 100644 --- a/anda_engine/src/context/mod.rs +++ b/anda_engine/src/context/mod.rs @@ -7,3 +7,155 @@ pub use agent::*; pub use base::*; pub use cache::*; pub use tee::*; + +pub mod mock { + use anda_core::{BoxError, CanisterCaller}; + use candid::{encode_args, utils::ArgumentEncoder, CandidType, Decode, Principal}; + + /// A mock implementation of CanisterCaller for testing purposes. + /// + /// This struct allows you to simulate canister calls by providing a transformation function + /// that takes the canister ID, method name, and arguments, and returns a response. + /// + /// # Example + /// ```rust + /// use anda_engine::context::mock::MockCanisterCaller; + /// use anda_core::CanisterCaller; + /// use candid::{encode_args, CandidType, Deserialize, Principal}; + /// + /// #[derive(CandidType, Deserialize, Debug, PartialEq)] + /// struct TestResponse { + /// canister: Principal, + /// method: String, + /// args: Vec, + /// } + /// + /// #[tokio::test] + /// async fn test_mock_canister_caller() { + /// let canister_id = Principal::anonymous(); + /// let empty_args = encode_args(()).unwrap(); + /// + /// let caller = MockCanisterCaller::new(|canister, method, args| { + /// let response = TestResponse { + /// canister: canister.clone(), + /// method: method.to_string(), + /// args, + /// }; + /// candid::encode_args((response,)).unwrap() + /// }); + /// + /// let res: TestResponse = caller + /// .canister_query(&canister_id, "canister_query", ()) + /// .await + /// .unwrap(); + /// assert_eq!(res.canister, canister_id); + /// assert_eq!(res.method, "canister_query"); + /// assert_eq!(res.args, empty_args); + /// + /// let res: TestResponse = caller + /// .canister_update(&canister_id, "canister_update", ()) + /// .await + /// .unwrap(); + /// assert_eq!(res.canister, canister_id); + /// assert_eq!(res.method, "canister_update"); + /// assert_eq!(res.args, empty_args); + /// } + /// ``` + pub struct MockCanisterCaller) -> Vec + Send + Sync> { + transform: F, + } + + impl MockCanisterCaller + where + F: Fn(&Principal, &str, Vec) -> Vec + Send + Sync, + { + /// Creates a new MockCanisterCaller with the provided transformation function. + /// + /// # Arguments + /// * `transform` - A function that takes (canister_id, method_name, args) and returns + /// a serialized response + pub fn new(transform: F) -> Self { + Self { transform } + } + } + + impl CanisterCaller for MockCanisterCaller + where + F: Fn(&Principal, &str, Vec) -> Vec + Send + Sync, + { + async fn canister_query< + In: ArgumentEncoder + Send, + Out: CandidType + for<'a> candid::Deserialize<'a>, + >( + &self, + canister: &Principal, + method: &str, + args: In, + ) -> Result { + let args = encode_args(args)?; + let res = (self.transform)(canister, method, args); + let output = Decode!(res.as_slice(), Out)?; + Ok(output) + } + + async fn canister_update< + In: ArgumentEncoder + Send, + Out: CandidType + for<'a> candid::Deserialize<'a>, + >( + &self, + canister: &Principal, + method: &str, + args: In, + ) -> Result { + let args = encode_args(args)?; + let res = (self.transform)(canister, method, args); + let output = Decode!(res.as_slice(), Out)?; + Ok(output) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use anda_core::CanisterCaller; + use candid::{encode_args, CandidType, Deserialize, Principal}; + + #[derive(CandidType, Deserialize, Debug, PartialEq)] + struct TestResponse { + canister: Principal, + method: String, + args: Vec, + } + + #[tokio::test(flavor = "current_thread")] + async fn test_mock_canister_caller() { + let canister_id = Principal::anonymous(); + let empty_args = encode_args(()).unwrap(); + + let caller = mock::MockCanisterCaller::new(|canister, method, args| { + let response = TestResponse { + canister: *canister, + method: method.to_string(), + args, + }; + candid::encode_args((response,)).unwrap() + }); + + let res: TestResponse = caller + .canister_query(&canister_id, "canister_query", ()) + .await + .unwrap(); + assert_eq!(res.canister, canister_id); + assert_eq!(res.method, "canister_query"); + assert_eq!(res.args, empty_args); + + let res: TestResponse = caller + .canister_update(&canister_id, "canister_update", ()) + .await + .unwrap(); + assert_eq!(res.canister, canister_id); + assert_eq!(res.method, "canister_update"); + assert_eq!(res.args, empty_args); + } +} diff --git a/anda_engine/src/extension/extractor.rs b/anda_engine/src/extension/extractor.rs index 897fe0c..827415f 100644 --- a/anda_engine/src/extension/extractor.rs +++ b/anda_engine/src/extension/extractor.rs @@ -69,11 +69,14 @@ where format!("submit_{}", self.name) } + fn description(&self) -> String { + "Submit the structured data you extracted from the provided text.".to_string() + } + fn definition(&self) -> FunctionDefinition { FunctionDefinition { name: self.name(), - description: "Submit the structured data you extracted from the provided text." - .to_string(), + description: self.description(), parameters: self.schema.clone(), strict: Some(true), } diff --git a/tools/anda_icp/Cargo.toml b/tools/anda_icp/Cargo.toml new file mode 100644 index 0000000..6122227 --- /dev/null +++ b/tools/anda_icp/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "anda_icp" +description = "Anda agent tools offers integration with the Internet Computer (ICP)." +repository = "https://github.com/ldclabs/anda/tree/main/tools/anda_icp" +publish = true +version = "0.1.0" +edition.workspace = true +keywords.workspace = true +categories.workspace = true +license.workspace = true + +[dependencies] +anda_core = { path = "../../anda_core", version = "0.3" } +anda_engine = { path = "../../anda_engine", version = "0.3" } +async-trait = { workspace = true } +candid = { workspace = true } +bytes = { workspace = true } +ciborium = { workspace = true } +futures = { workspace = true } +futures-util = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_bytes = { workspace = true } +http = { workspace = true } +object_store = { workspace = true } +ic_cose = { workspace = true } +ic_cose_types = { workspace = true } +tokio-util = { workspace = true } +structured-logger = { workspace = true } +reqwest = { workspace = true } +rand = { workspace = true } +moka = { workspace = true } +toml = { workspace = true } +tokio = { workspace = true } +log = { workspace = true } +chrono = { workspace = true } +schemars = { workspace = true } +icrc-ledger-types = "0.1" + +[dev-dependencies] +dotenv = { workspace = true } diff --git a/tools/anda_icp/src/ledger.rs b/tools/anda_icp/src/ledger.rs new file mode 100644 index 0000000..9198409 --- /dev/null +++ b/tools/anda_icp/src/ledger.rs @@ -0,0 +1,191 @@ +use anda_core::{BoxError, CanisterCaller, FunctionDefinition, Tool}; +use anda_engine::context::BaseCtx; +use candid::{Nat, Principal}; +use icrc_ledger_types::icrc1::{ + account::{principal_to_subaccount, Account}, + transfer::{Memo, TransferArg, TransferError}, +}; +use schemars::{schema_for, JsonSchema}; +use serde::{Deserialize, Serialize}; +use serde_bytes::ByteBuf; +use serde_json::{json, Value}; + +const MAX_MEMO_LEN: usize = 32; + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +pub struct TransferToArgs { + account: String, + amount: f64, + memo: Option, // should less than 32 bytes +} + +#[derive(Debug, Clone)] +pub struct ICPLedgerTransfer { + canister: Principal, + schema: Value, + symbol: String, + decimals: u8, + from_user_subaccount: bool, +} + +impl ICPLedgerTransfer { + pub fn new( + canister: Principal, + symbol: String, // token symbol, e.g. "ICP" + decimals: u8, // token decimals, e.g. 8 + from_user_subaccount: bool, + ) -> ICPLedgerTransfer { + let mut schema = schema_for!(TransferToArgs); + schema.meta_schema = None; // Remove the $schema field + + ICPLedgerTransfer { + canister, + schema: json!(schema), + symbol, + decimals, + from_user_subaccount, + } + } + + async fn transfer( + &self, + ctx: &impl CanisterCaller, + data: TransferToArgs, + ) -> Result { + let owner = Principal::from_text(&data.account)?; + let from_subaccount = if self.from_user_subaccount { + Some(principal_to_subaccount(owner)) + } else { + None + }; + + let amount = (data.amount * 10u64.pow(self.decimals as u32) as f64) as u64; + let res: Result = ctx + .canister_update( + &self.canister, + "icrc1_transfer", + (TransferArg { + from_subaccount, + to: Account { + owner, + subaccount: None, + }, + amount: amount.into(), + memo: data.memo.map(|m| { + let mut buf = String::new(); + for c in m.chars() { + if buf.len() + c.len_utf8() > MAX_MEMO_LEN { + break; + } + buf.push(c); + } + Memo(ByteBuf::from(buf.into_bytes())) + }), + fee: None, + created_at_time: None, + },), + ) + .await?; + res.map_err(|err| format!("failed to transfer tokens, error: {:?}", err).into()) + } +} + +impl Tool for ICPLedgerTransfer { + const CONTINUE: bool = true; + type Args = TransferToArgs; + type Output = Nat; + + fn name(&self) -> String { + format!("icp_ledger_transfer_{}", self.symbol) + } + + fn description(&self) -> String { + format!( + "Transfer {} tokens to the specified account on ICP network.", + self.symbol + ) + } + + fn definition(&self) -> FunctionDefinition { + FunctionDefinition { + name: self.name(), + description: self.description(), + parameters: self.schema.clone(), + strict: Some(true), + } + } + + async fn call(&self, ctx: BaseCtx, data: Self::Args) -> Result { + self.transfer(&ctx, data).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use anda_engine::context::mock; + use candid::{decode_args, encode_args}; + + #[tokio::test(flavor = "current_thread")] + async fn test_icp_ledger_transfer() { + let ledger = Principal::from_text("druyg-tyaaa-aaaaq-aactq-cai").unwrap(); + let tool = ICPLedgerTransfer::new(ledger, "PANDA".to_string(), 8, true); + let definition = tool.definition(); + assert_eq!(definition.name, "icp_ledger_transfer_PANDA"); + let s = serde_json::to_string_pretty(&definition).unwrap(); + println!("{}", s); + // { + // "name": "icp_ledger_transfer_PANDA", + // "description": "Transfer PANDA tokens to the specified account on ICP network.", + // "parameters": { + // "properties": { + // "account": { + // "type": "string" + // }, + // "amount": { + // "format": "double", + // "type": "number" + // }, + // "memo": { + // "type": [ + // "string", + // "null" + // ] + // } + // }, + // "required": [ + // "account", + // "amount" + // ], + // "title": "TransferToArgs", + // "type": "object" + // }, + // "strict": true + // } + + let args = TransferToArgs { + account: Principal::anonymous().to_string(), + amount: 9999.000012345678, + memo: Some("test memo".to_string()), + }; + let mocker = mock::MockCanisterCaller::new(|canister, method, args| { + assert_eq!(canister, &ledger); + assert_eq!(method, "icrc1_transfer"); + let (args,): (TransferArg,) = decode_args(&args).unwrap(); + println!("{:?}", args); + assert_eq!( + args.from_subaccount, + Some(principal_to_subaccount(Principal::anonymous())) + ); + assert_eq!(args.to.owner, Principal::anonymous()); + assert_eq!(args.amount, Nat::from(999900001234u64)); + assert_eq!(args.memo, Some(Memo(ByteBuf::from("test memo".as_bytes())))); + + let res: Result = Ok(Nat::from(321u64)); + encode_args((res,)).unwrap() + }); + + let res = tool.transfer(&mocker, args).await.unwrap(); + assert_eq!(res, Nat::from(321u64)); + } +} diff --git a/tools/anda_icp/src/lib.rs b/tools/anda_icp/src/lib.rs new file mode 100644 index 0000000..370e115 --- /dev/null +++ b/tools/anda_icp/src/lib.rs @@ -0,0 +1 @@ +pub mod ledger;