From 8353b6f2349db9124cc4c20d50b3985c47aa91cb Mon Sep 17 00:00:00 2001 From: Pranav Raja Date: Thu, 26 Mar 2026 19:41:42 -0700 Subject: [PATCH] intitial --- Cargo.toml | 1 + Dockerfile | 6 +- skills/abound-remittance/SKILL.md | 94 +++++ src/tools/builtin/abound.rs | 563 ++++++++++++++++++++++++++++++ src/tools/builtin/mod.rs | 7 + src/tools/registry.rs | 15 + 6 files changed, 685 insertions(+), 1 deletion(-) create mode 100644 skills/abound-remittance/SKILL.md create mode 100644 src/tools/builtin/abound.rs diff --git a/Cargo.toml b/Cargo.toml index 2d1d5ce6..15aed23c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -232,6 +232,7 @@ libsql = ["dep:libsql"] integration = [] html-to-markdown = ["dep:html-to-markdown-rs", "dep:readabilityrs"] bedrock = ["dep:aws-config", "dep:aws-sdk-bedrockruntime", "dep:aws-smithy-types"] +demo = [] import = ["dep:json5", "libsql"] [[test]] diff --git a/Dockerfile b/Dockerfile index 14e245b4..c119f843 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,7 +58,9 @@ COPY channels-src/ channels-src/ COPY wit/ wit/ COPY providers.json providers.json -RUN cargo build --release --bin ironclaw +COPY skills/ skills/ + +RUN cargo build --release --features demo --bin ironclaw # Stage 5: Runtime FROM debian:bookworm-slim @@ -70,6 +72,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY --from=builder /app/target/release/ironclaw /usr/local/bin/ironclaw COPY --from=builder /app/migrations /app/migrations +COPY --from=builder /app/skills /app/skills # Non-root user RUN useradd -m -u 1000 -s /bin/bash ironclaw @@ -78,5 +81,6 @@ USER ironclaw EXPOSE 3000 ENV RUST_LOG=ironclaw=info +ENV SKILLS_DIR=/app/skills ENTRYPOINT ["ironclaw"] diff --git a/skills/abound-remittance/SKILL.md b/skills/abound-remittance/SKILL.md new file mode 100644 index 00000000..67f9a721 --- /dev/null +++ b/skills/abound-remittance/SKILL.md @@ -0,0 +1,94 @@ +--- +name: abound-remittance +version: 0.1.0 +description: Smart remittance assistant for Abound — helps users send money to India with intelligent forex timing and transfer management. +activation: + keywords: + - send money + - transfer + - remittance + - exchange rate + - forex + - INR + - India + - wire + - schedule trade + - trade tomorrow + - convert currency + - send dollars + - rupees + - beneficiary + - funding source + - payment + - how much + - rate today + - best time + - family maintenance + patterns: + - "send \\$?\\d+" + - "schedule.*(trade|transfer|send|wire)" + - "how much.*(INR|rupees|India)" + - "best time to (send|transfer|convert)" + - "(rate|forex).*(good|bad|high|low|today|now)" + - "transfer.*tomorrow|tomorrow.*transfer" + tags: + - fintech + - remittance + - forex + max_context_tokens: 2500 +--- + +# Abound Remittance Assistant + +You are a smart remittance assistant for Abound, helping users send money from USD to INR (India) with intelligent timing advice. + +## Available Tools + +You have these Abound-specific tools: + +- **abound_get_account_info** — Get the user's account: limits, recipients, funding sources, payment reasons +- **abound_get_exchange_rate** — Get current USD/INR exchange rate (current + effective after fees) +- **abound_get_forex_score** — Get a 0-100 forex timing score with a signal (convert_now / split_transfer / wait) +- **abound_send_wire** — Execute a wire transfer (requires: funding_source_id, beneficiary_ref_id, amount, payment_reason_key) +- **abound_create_notification** — Send a notification to the user's Abound app + +You also have **routine_create** for scheduling future/recurring transfers. + +## Workflow: "Send $X" or "Transfer money" + +1. **Always check the rate first.** Call `abound_get_exchange_rate` to get the current rate. +2. **Check the forex score.** Call `abound_get_forex_score` to assess timing. +3. **Get account info.** Call `abound_get_account_info` to know the user's limits, recipients, and funding sources. +4. **Advise based on the score:** + - **Score >= 60 (convert_now):** Tell the user it's a good time. Show the rate, the INR equivalent of their amount, and recommend proceeding. + - **Score 40-59 (split_transfer):** Suggest splitting — send half now at the current rate, schedule the rest for later when the rate may improve. + - **Score < 40 (wait):** Unless the transfer is urgent, recommend waiting. Explain why (rate below average, unfavorable season). +5. **Execute if user confirms.** Use `abound_send_wire` with the correct funding_source_id, beneficiary_ref_id, amount, and payment_reason_key from the account info. +6. **Notify.** After a successful wire, call `abound_create_notification` with relevant metadata. + +## Workflow: "Schedule a trade" or "Send tomorrow morning" + +1. Gather the same info (rate, score, account). +2. Use **routine_create** to schedule the transfer: + - For "tomorrow morning": use cron `"0 9 * * *"` with the user's timezone, set to fire once + - For "every week": use cron `"0 9 * * MON"` (or the user's preferred day) + - The routine prompt should instruct the agent to check the rate and execute the wire +3. Confirm the schedule with the user, showing when it will fire. + +## Presentation Rules + +- Always show amounts in **both USD and INR**: "$1,000 (~INR 85,420 at today's rate of 85.42)" +- Show the **effective rate** (after fees), not just the market rate +- When showing the forex score, explain it simply: "The forex timing score is 72/100 — this is a good time to send." +- If the user's amount exceeds their limit ($5,000), tell them and suggest splitting into multiple transfers +- Always mention the **estimated delivery time** (1-3 business days) after a wire + +## Payment Reasons + +When asking about the purpose, offer these options: +- Family Maintenance +- Gift +- Education Support +- Medical Support + +If the user doesn't specify, ask which applies. diff --git a/src/tools/builtin/abound.rs b/src/tools/builtin/abound.rs new file mode 100644 index 00000000..79a051ce --- /dev/null +++ b/src/tools/builtin/abound.rs @@ -0,0 +1,563 @@ +//! Mock Abound API tools for demo purposes. +//! +//! These tools simulate Abound's backend API (account info, wire transfers, +//! exchange rates, notifications, forex scoring) with realistic mock data. +//! They are feature-gated behind `--features demo` and will be replaced by +//! real WASM tools once Abound's backend is live. + +use std::time::Instant; + +use async_trait::async_trait; +use chrono::{Datelike, Utc}; +use rand::Rng; +use serde_json::json; + +use crate::context::JobContext; +use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput, require_str}; + +// --------------------------------------------------------------------------- +// Tool 1: Get Account Info +// --------------------------------------------------------------------------- + +/// Returns mock Abound account data (limits, recipients, funding sources). +pub struct AboundGetAccountInfoTool; + +#[async_trait] +impl Tool for AboundGetAccountInfoTool { + fn name(&self) -> &str { + "abound_get_account_info" + } + + fn description(&self) -> &str { + "Retrieve the authenticated user's Abound account information including \ + transfer limits, payment reasons, recipients, and funding sources." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": {}, + "required": [] + }) + } + + async fn execute( + &self, + _params: serde_json::Value, + _ctx: &JobContext, + ) -> Result { + let start = Instant::now(); + + let data = json!({ + "status": "success", + "data": { + "user_id": "acc_123456", + "user_name": "John Doe", + "limits": { + "ach_limit": { + "limit": 5000, + "formatted_limit": "$5,000" + } + }, + "payment_reasons": [ + { "key": "FAMILY_MAINTENANCE", "value": "Family Maintenance" }, + { "key": "GIFT", "value": "Gift" }, + { "key": "EDUCATION_SUPPORT", "value": "Education Support" }, + { "key": "MEDICAL_SUPPORT", "value": "Medical Support" } + ], + "recipients": [ + { + "beneficiary_ref_id": "ben_001", + "name": "Rahul Sharma", + "mask": "****2222" + } + ], + "funding_sources": [ + { + "funding_source_id": "fs_001", + "bank_name": "HDFC Bank", + "mask": "****2222" + } + ] + } + }); + + Ok(ToolOutput::success(data, start.elapsed())) + } + + fn requires_sanitization(&self) -> bool { + false + } +} + +// --------------------------------------------------------------------------- +// Tool 2: Get Exchange Rate +// --------------------------------------------------------------------------- + +/// Returns mock USD/INR exchange rate with slight randomization. +pub struct AboundGetExchangeRateTool; + +/// Generate a mock USD/INR rate with slight jitter around 85.42. +fn mock_exchange_rate() -> f64 { + let mut rng = rand::thread_rng(); + let jitter: f64 = rng.gen_range(-0.30..=0.30); + ((85.42 + jitter) * 100.0).round() / 100.0 +} + +#[async_trait] +impl Tool for AboundGetExchangeRateTool { + fn name(&self) -> &str { + "abound_get_exchange_rate" + } + + fn description(&self) -> &str { + "Get the current USD to INR exchange rate including the effective rate \ + after fees. Use this before initiating any wire transfer." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": {}, + "required": [] + }) + } + + async fn execute( + &self, + _params: serde_json::Value, + _ctx: &JobContext, + ) -> Result { + let start = Instant::now(); + + let rate = mock_exchange_rate(); + let effective = ((rate - 0.32) * 100.0).round() / 100.0; + + let data = json!({ + "status": "success", + "data": { + "from_currency": "USD", + "to_currency": "INR", + "current_exchange_rate": { + "formatted_value": format!("{rate:.2}"), + "value": rate + }, + "effective_exchange_rate": { + "formatted_value": format!("{effective:.2}"), + "value": effective + } + } + }); + + Ok(ToolOutput::success(data, start.elapsed())) + } + + fn requires_sanitization(&self) -> bool { + false + } +} + +// --------------------------------------------------------------------------- +// Tool 3: Send Wire +// --------------------------------------------------------------------------- + +/// Simulates a wire transfer. Requires approval before execution. +pub struct AboundSendWireTool; + +#[async_trait] +impl Tool for AboundSendWireTool { + fn name(&self) -> &str { + "abound_send_wire" + } + + fn description(&self) -> &str { + "Submit a wire transfer to send USD to an INR recipient. Requires a \ + funding source, beneficiary, amount in USD, and payment reason. \ + The transfer amount must not exceed the user's ACH limit." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "funding_source_id": { + "type": "string", + "description": "Funding source ID (e.g. 'fs_001')" + }, + "beneficiary_ref_id": { + "type": "string", + "description": "Beneficiary reference ID (e.g. 'ben_001')" + }, + "amount": { + "type": "number", + "description": "Amount in USD to send" + }, + "payment_reason_key": { + "type": "string", + "description": "Payment reason key: FAMILY_MAINTENANCE, GIFT, EDUCATION_SUPPORT, or MEDICAL_SUPPORT" + } + }, + "required": ["funding_source_id", "beneficiary_ref_id", "amount", "payment_reason_key"] + }) + } + + async fn execute( + &self, + params: serde_json::Value, + _ctx: &JobContext, + ) -> Result { + let start = Instant::now(); + + let _funding_source = require_str(¶ms, "funding_source_id")?; + let _beneficiary = require_str(¶ms, "beneficiary_ref_id")?; + let _reason = require_str(¶ms, "payment_reason_key")?; + + let amount = params + .get("amount") + .and_then(|v| v.as_f64()) + .ok_or_else(|| { + ToolError::InvalidParameters("missing or invalid 'amount' parameter".to_string()) + })?; + + // Enforce mock ACH limit + if amount > 5000.0 { + let data = json!({ + "status": "error", + "error": { + "code": "TRANSFER_NOT_ALLOWED", + "message": format!( + "Transfer amount ${:.2} exceeds your ACH limit of $5,000.00", + amount + ) + } + }); + return Ok(ToolOutput::success(data, start.elapsed())); + } + + if amount <= 0.0 { + return Err(ToolError::InvalidParameters( + "amount must be greater than zero".to_string(), + )); + } + + let txn_id = uuid::Uuid::new_v4(); + let trk_id = uuid::Uuid::new_v4(); + + let data = json!({ + "status": "success", + "data": { + "transaction_id": format!("txn_{}", &txn_id.to_string()[..8]), + "tracking_id": format!("trk_{}", &trk_id.to_string()[..8]), + "amount_usd": amount, + "completion_time": { + "min_calendar_days": 1, + "min_business_days": 1, + "max_calendar_days": 3, + "max_business_days": 2 + } + } + }); + + Ok(ToolOutput::success(data, start.elapsed())) + } + + fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement { + ApprovalRequirement::UnlessAutoApproved + } + + fn requires_sanitization(&self) -> bool { + false + } +} + +// --------------------------------------------------------------------------- +// Tool 4: Create Notification +// --------------------------------------------------------------------------- + +/// Simulates sending a notification to the Abound system. +pub struct AboundCreateNotificationTool; + +#[async_trait] +impl Tool for AboundCreateNotificationTool { + fn name(&self) -> &str { + "abound_create_notification" + } + + fn description(&self) -> &str { + "Create a notification in the Abound app (e.g. rate alert, transfer \ + confirmation, forex scoring signal). Returns 202 accepted." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "message_id": { + "type": "string", + "description": "Unique message identifier" + }, + "action_type": { + "type": "string", + "description": "Notification type: 'notification' or 'token_refresh'" + }, + "meta_data": { + "type": "object", + "description": "Additional metadata (e.g. score, rate, signal)" + } + }, + "required": ["message_id", "action_type"] + }) + } + + async fn execute( + &self, + params: serde_json::Value, + _ctx: &JobContext, + ) -> Result { + let start = Instant::now(); + + let message_id = require_str(¶ms, "message_id")?; + + let data = json!({ + "status": "accepted", + "message": "Notification request accepted for processing", + "data": { + "message_id": message_id + } + }); + + Ok(ToolOutput::success(data, start.elapsed())) + } + + fn requires_sanitization(&self) -> bool { + false + } +} + +// --------------------------------------------------------------------------- +// Tool 5: Forex Score +// --------------------------------------------------------------------------- + +/// Seasonal bias factors by month (1-indexed: Jan=1 .. Dec=12). +/// High bias in Oct-Mar (favorable remittance window), low Apr-Sep. +const SEASONAL_BIAS: [f64; 13] = [ + 0.0, // placeholder for 0-index + 0.75, // Jan + 0.70, // Feb + 0.65, // Mar + 0.35, // Apr + 0.30, // May + 0.25, // Jun + 0.25, // Jul + 0.30, // Aug + 0.35, // Sep + 0.70, // Oct + 0.75, // Nov + 0.65, // Dec +]; + +/// Computes a forex timing score for USD/INR, biased toward interesting +/// signals (60-80 range) for demo purposes. +pub struct AboundGetForexScoreTool; + +#[async_trait] +impl Tool for AboundGetForexScoreTool { + fn name(&self) -> &str { + "abound_get_forex_score" + } + + fn description(&self) -> &str { + "Compute a forex timing score (0-100) for USD/INR transfers. Returns \ + a score with a signal: 'convert_now' (>=60, good time to send), \ + 'split_transfer' (40-59, send half now), or 'wait' (<40, hold off). \ + Use this to advise users on optimal transfer timing." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": {}, + "required": [] + }) + } + + async fn execute( + &self, + _params: serde_json::Value, + _ctx: &JobContext, + ) -> Result { + let start = Instant::now(); + + let mut rng = rand::thread_rng(); + + // Mock current rate and MA50 + let rate = mock_exchange_rate(); + let ma50_jitter: f64 = rng.gen_range(-0.20..=0.20); + let ma50 = ((84.50 + ma50_jitter) * 100.0).round() / 100.0; + + // Get seasonal bias for current month + let month = Utc::now().month() as usize; + let month_bias = SEASONAL_BIAS[month.clamp(1, 12)]; + + // Scoring weights + let w_ma = 0.7; + let w_s = 0.3; + + // MA-based signal: how far current rate is above the 50-day average + let ma_signal = (50.0 + ((rate - ma50) / ma50 * 100.0) * 15.0).clamp(0.0, 100.0); + + // Combined score + let raw_score = (ma_signal * w_ma + month_bias * 100.0 * w_s) / (w_ma + w_s); + + // Bias toward 60-80 for demo (blend raw score with a favorable base) + let demo_base = 68.0; + let score = ((raw_score * 0.4 + demo_base * 0.6) as u32).clamp(55, 85); + + let signal = if score >= 60 { + "convert_now" + } else if score >= 40 { + "split_transfer" + } else { + "wait" + }; + + let explanation = match signal { + "convert_now" => format!( + "The current USD/INR rate of {rate:.2} is above the 50-day moving average \ + of {ma50:.2}, and seasonal trends are favorable. This is a good time to \ + convert and send money." + ), + "split_transfer" => format!( + "The rate of {rate:.2} is near the 50-day average of {ma50:.2}. Consider \ + splitting your transfer \u{2014} send half now and hold the rest for a \ + potentially better rate." + ), + _ => format!( + "The current rate of {rate:.2} is below the 50-day average of {ma50:.2}. \ + Unless urgent, consider waiting for a better rate." + ), + }; + + let data = json!({ + "score": score, + "signal": signal, + "rate": rate, + "ma50": ma50, + "month_bias": month_bias, + "explanation": explanation + }); + + Ok(ToolOutput::success(data, start.elapsed())) + } + + fn requires_sanitization(&self) -> bool { + false + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::JobContext; + + fn test_ctx() -> JobContext { + JobContext::with_user("test-user", "test", "mock abound tool test") + } + + #[tokio::test] + async fn account_info_returns_valid_json() { + let tool = AboundGetAccountInfoTool; + let result = tool.execute(json!({}), &test_ctx()).await.unwrap(); + let data = &result.result["data"]; + assert_eq!(data["user_id"], "acc_123456"); + assert_eq!(data["limits"]["ach_limit"]["limit"], 5000); + assert_eq!(data["recipients"].as_array().unwrap().len(), 1); + assert_eq!(data["funding_sources"].as_array().unwrap().len(), 1); + } + + #[tokio::test] + async fn exchange_rate_returns_valid_range() { + let tool = AboundGetExchangeRateTool; + let result = tool.execute(json!({}), &test_ctx()).await.unwrap(); + let rate = result.result["data"]["current_exchange_rate"]["value"] + .as_f64() + .unwrap(); + // Rate should be within jitter range of base 85.42 + assert!(rate > 85.0 && rate < 85.8, "rate {rate} out of range"); + } + + #[tokio::test] + async fn send_wire_succeeds_within_limit() { + let tool = AboundSendWireTool; + let params = json!({ + "funding_source_id": "fs_001", + "beneficiary_ref_id": "ben_001", + "amount": 1000.0, + "payment_reason_key": "FAMILY_MAINTENANCE" + }); + let result = tool.execute(params, &test_ctx()).await.unwrap(); + assert_eq!(result.result["status"], "success"); + assert!(result.result["data"]["transaction_id"] + .as_str() + .unwrap() + .starts_with("txn_")); + } + + #[tokio::test] + async fn send_wire_rejects_over_limit() { + let tool = AboundSendWireTool; + let params = json!({ + "funding_source_id": "fs_001", + "beneficiary_ref_id": "ben_001", + "amount": 6000.0, + "payment_reason_key": "FAMILY_MAINTENANCE" + }); + let result = tool.execute(params, &test_ctx()).await.unwrap(); + assert_eq!(result.result["status"], "error"); + assert_eq!(result.result["error"]["code"], "TRANSFER_NOT_ALLOWED"); + } + + #[test] + fn send_wire_requires_approval() { + let tool = AboundSendWireTool; + assert!(matches!( + tool.requires_approval(&json!({})), + ApprovalRequirement::UnlessAutoApproved + )); + } + + #[tokio::test] + async fn create_notification_returns_accepted() { + let tool = AboundCreateNotificationTool; + let params = json!({ + "message_id": "msg_001", + "action_type": "notification", + "meta_data": { "score": 72 } + }); + let result = tool.execute(params, &test_ctx()).await.unwrap(); + assert_eq!(result.result["status"], "accepted"); + } + + #[tokio::test] + async fn forex_score_in_demo_range() { + let tool = AboundGetForexScoreTool; + // Run multiple times to check range stability + for _ in 0..20 { + let result = tool.execute(json!({}), &test_ctx()).await.unwrap(); + let score = result.result["score"].as_u64().unwrap(); + assert!( + (55..=85).contains(&score), + "score {score} outside demo range [55, 85]" + ); + let signal = result.result["signal"].as_str().unwrap(); + assert!( + signal == "convert_now" || signal == "split_transfer" || signal == "wait", + "unexpected signal: {signal}" + ); + } + } +} diff --git a/src/tools/builtin/mod.rs b/src/tools/builtin/mod.rs index d196b12c..fe3c4632 100644 --- a/src/tools/builtin/mod.rs +++ b/src/tools/builtin/mod.rs @@ -1,5 +1,7 @@ //! Built-in tools that come with the agent. +#[cfg(feature = "demo")] +mod abound; mod echo; pub mod extension_tools; mod file; @@ -17,6 +19,11 @@ pub mod skill_tools; mod time; mod tool_info; +#[cfg(feature = "demo")] +pub use abound::{ + AboundCreateNotificationTool, AboundGetAccountInfoTool, AboundGetExchangeRateTool, + AboundGetForexScoreTool, AboundSendWireTool, +}; pub use echo::EchoTool; pub use extension_tools::{ ExtensionInfoTool, ToolActivateTool, ToolAuthTool, ToolInstallTool, ToolListTool, diff --git a/src/tools/registry.rs b/src/tools/registry.rs index 8c08633b..b577a23a 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -16,6 +16,11 @@ use crate::skills::registry::SkillRegistry; use crate::tools::builder::{ BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder, SoftwareBuilder, }; +#[cfg(feature = "demo")] +use crate::tools::builtin::{ + AboundCreateNotificationTool, AboundGetAccountInfoTool, AboundGetExchangeRateTool, + AboundGetForexScoreTool, AboundSendWireTool, +}; use crate::tools::builtin::{ ApplyPatchTool, CancelJobTool, CreateJobTool, EchoTool, ExtensionInfoTool, HttpTool, JobEventsTool, JobPromptTool, JobStatusTool, JsonTool, ListDirTool, ListJobsTool, @@ -248,6 +253,16 @@ impl ToolRegistry { } self.register_sync(Arc::new(http)); + // Abound demo tools (mock APIs — only compiled with --features demo) + #[cfg(feature = "demo")] + { + self.register_sync(Arc::new(AboundGetAccountInfoTool)); + self.register_sync(Arc::new(AboundGetExchangeRateTool)); + self.register_sync(Arc::new(AboundSendWireTool)); + self.register_sync(Arc::new(AboundCreateNotificationTool)); + self.register_sync(Arc::new(AboundGetForexScoreTool)); + } + tracing::debug!("Registered {} built-in tools", self.count()); }