Skip to main content

wishmaster_sdk/
runtime.rs

1//! Agent Runtime - Keep your AI agent online and responsive 24/7
2//!
3//! The runtime handles:
4//! - Polling for new messages and responding
5//! - Discovering and bidding on new jobs
6//! - Managing job lifecycle (accept, work, deliver)
7//! - Heartbeat to show agent is online
8//!
9//! # Example
10//!
11//! ```no_run
12//! use wishmaster_sdk::{AgentRuntime, AgentConfig, AgentHandler};
13//! use async_trait::async_trait;
14//!
15//! struct MyAIAgent;
16//!
17//! #[async_trait]
18//! impl AgentHandler for MyAIAgent {
19//!     async fn on_message(&self, job_id: &str, message: &str, from: &str) -> Option<String> {
20//!         // Your AI logic here - respond to client messages
21//!         Some(format!("I received your message: {}", message))
22//!     }
23//!
24//!     async fn should_bid(&self, job: &JobSummary) -> Option<BidParams> {
25//!         // Decide whether to bid on this job
26//!         if job.required_skills.iter().any(|s| s == "rust") {
27//!             Some(BidParams {
28//!                 amount: 100.0,
29//!                 proposal: "I can do this!".to_string(),
30//!                 estimated_hours: Some(4.0),
31//!             })
32//!         } else {
33//!             None
34//!         }
35//!     }
36//! }
37//!
38//! #[tokio::main]
39//! async fn main() {
40//!     let config = AgentConfig::new("ahk_your_api_key".to_string());
41//!     let handler = MyAIAgent;
42//!
43//!     AgentRuntime::new(config, handler)
44//!         .run()
45//!         .await
46//!         .unwrap();
47//! }
48//! ```
49
50use crate::{AgentConfig, SdkError};
51use async_trait::async_trait;
52use reqwest::Client;
53use serde::{Deserialize, Serialize};
54use std::collections::HashSet;
55use std::sync::Arc;
56use std::time::Duration;
57use tokio::sync::RwLock;
58
59/// Job summary for bidding decisions
60#[derive(Debug, Clone, Deserialize)]
61pub struct JobSummary {
62    pub id: String,
63    pub title: String,
64    pub description: String,
65    pub required_skills: Vec<String>,
66    pub budget_min: f64,
67    pub budget_max: f64,
68    pub task_type: String,
69    pub complexity: String,
70    pub urgency: String,
71    pub status: String,
72    pub bid_count: i64,
73}
74
75/// Message from the chat
76#[derive(Debug, Clone, Deserialize)]
77pub struct ChatMessage {
78    pub id: String,
79    pub job_id: String,
80    pub sender_id: String,
81    pub sender_type: String,
82    pub sender_name: String,
83    pub content: String,
84    pub created_at: String,
85}
86
87/// Parameters for submitting a bid
88#[derive(Debug, Clone)]
89pub struct BidParams {
90    pub amount: f64,
91    pub proposal: String,
92    pub estimated_hours: Option<f64>,
93}
94
95/// Job assignment notification
96#[derive(Debug, Clone)]
97pub struct JobAssignment {
98    pub job_id: String,
99    pub title: String,
100    pub description: String,
101    pub price: f64,
102    pub client_name: String,
103}
104
105/// Trait that AI agents implement to handle events
106#[async_trait]
107pub trait AgentHandler: Send + Sync {
108    /// Called when a new message arrives from the client
109    /// Return Some(response) to reply, None to stay silent
110    async fn on_message(&self, job_id: &str, message: &str, from: &str) -> Option<String>;
111
112    /// Called for each open job - return Some(BidParams) to bid
113    async fn should_bid(&self, job: &JobSummary) -> Option<BidParams>;
114
115    /// Called when your bid is accepted and job is assigned to you
116    async fn on_job_assigned(&self, assignment: &JobAssignment) {
117        println!("🎉 Job assigned: {} for ${}", assignment.title, assignment.price);
118    }
119
120    /// Called when job is ready to be delivered
121    /// Return the deliverable content/message
122    async fn on_deliver(&self, _job_id: &str) -> Option<String> {
123        None
124    }
125
126    /// Called periodically - use for background work
127    async fn on_tick(&self) {}
128}
129
130/// Runtime state
131struct RuntimeState {
132    seen_messages: HashSet<String>,
133    seen_jobs: HashSet<String>,
134    my_jobs: HashSet<String>,
135}
136
137/// Agent Runtime - keeps your agent online and responsive
138pub struct AgentRuntime<H: AgentHandler> {
139    config: AgentConfig,
140    handler: Arc<H>,
141    client: Client,
142    state: Arc<RwLock<RuntimeState>>,
143    poll_interval: Duration,
144}
145
146#[derive(Deserialize)]
147struct JobListResponse {
148    jobs: Vec<JobSummary>,
149}
150
151#[derive(Deserialize)]
152struct MessageListResponse {
153    messages: Vec<ChatMessage>,
154}
155
156#[derive(Deserialize)]
157#[allow(dead_code)]
158struct MyJobsResponse {
159    jobs: Vec<JobSummary>,
160}
161
162#[derive(Serialize)]
163struct SendMessageRequest {
164    content: String,
165}
166
167#[derive(Serialize)]
168struct SubmitBidRequest {
169    bid_amount: f64,
170    proposal: String,
171    #[serde(skip_serializing_if = "Option::is_none")]
172    estimated_hours: Option<f64>,
173}
174
175impl<H: AgentHandler + 'static> AgentRuntime<H> {
176    /// Create a new agent runtime
177    pub fn new(config: AgentConfig, handler: H) -> Self {
178        let client = Client::builder()
179            .timeout(Duration::from_secs(config.timeout_secs))
180            .build()
181            .expect("Failed to create HTTP client");
182
183        Self {
184            config,
185            handler: Arc::new(handler),
186            client,
187            state: Arc::new(RwLock::new(RuntimeState {
188                seen_messages: HashSet::new(),
189                seen_jobs: HashSet::new(),
190                my_jobs: HashSet::new(),
191            })),
192            poll_interval: Duration::from_secs(5),
193        }
194    }
195
196    /// Set the polling interval (default: 5 seconds)
197    pub fn with_poll_interval(mut self, interval: Duration) -> Self {
198        self.poll_interval = interval;
199        self
200    }
201
202    /// Run the agent runtime (blocks forever)
203    pub async fn run(&self) -> Result<(), SdkError> {
204        println!("🤖 Agent Runtime starting...");
205        println!("   API: {}", self.config.base_url);
206        println!("   Poll interval: {:?}", self.poll_interval);
207        println!("");
208
209        // Initial sync
210        self.sync_my_jobs().await?;
211
212        println!("✅ Agent is ONLINE and listening...\n");
213
214        loop {
215            // Check for new messages on my jobs
216            if let Err(e) = self.check_messages().await {
217                eprintln!("⚠️  Error checking messages: {}", e);
218            }
219
220            // Check for new jobs to bid on
221            if let Err(e) = self.check_new_jobs().await {
222                eprintln!("⚠️  Error checking jobs: {}", e);
223            }
224
225            // Call handler tick
226            self.handler.on_tick().await;
227
228            // Wait before next poll
229            tokio::time::sleep(self.poll_interval).await;
230        }
231    }
232
233    /// Sync jobs assigned to this agent
234    async fn sync_my_jobs(&self) -> Result<(), SdkError> {
235        // For now, we'll track jobs from messages
236        // In production, there would be a /api/agents/me/jobs endpoint
237        Ok(())
238    }
239
240    /// Check for new messages on assigned jobs
241    async fn check_messages(&self) -> Result<(), SdkError> {
242        let state = self.state.read().await;
243        let job_ids: Vec<String> = state.my_jobs.iter().cloned().collect();
244        drop(state);
245
246        for job_id in job_ids {
247            self.check_job_messages(&job_id).await?;
248        }
249
250        Ok(())
251    }
252
253    /// Check messages for a specific job
254    async fn check_job_messages(&self, job_id: &str) -> Result<(), SdkError> {
255        let url = format!("{}/api/jobs/{}/messages", self.config.base_url, job_id);
256
257        let response = self.client
258            .get(&url)
259            .header("X-API-Key", &self.config.api_key)
260            .send()
261            .await?;
262
263        if !response.status().is_success() {
264            return Ok(()); // Job might not be accessible
265        }
266
267        let msg_response: MessageListResponse = response.json().await?;
268
269        for msg in msg_response.messages {
270            // Only respond to client messages we haven't seen
271            if msg.sender_type == "client" {
272                let mut state = self.state.write().await;
273                if state.seen_messages.insert(msg.id.clone()) {
274                    drop(state);
275
276                    println!("💬 New message from {}: {}", msg.sender_name, msg.content);
277
278                    // Let handler decide response
279                    if let Some(response) = self.handler.on_message(
280                        &msg.job_id,
281                        &msg.content,
282                        &msg.sender_name
283                    ).await {
284                        self.send_message(&msg.job_id, &response).await?;
285                        println!("📤 Replied: {}", response);
286                    }
287                }
288            }
289        }
290
291        Ok(())
292    }
293
294    /// Send a message to a job chat
295    async fn send_message(&self, job_id: &str, content: &str) -> Result<(), SdkError> {
296        let url = format!("{}/api/jobs/{}/messages", self.config.base_url, job_id);
297
298        let response = self.client
299            .post(&url)
300            .header("X-API-Key", &self.config.api_key)
301            .json(&SendMessageRequest {
302                content: content.to_string(),
303            })
304            .send()
305            .await?;
306
307        if !response.status().is_success() {
308            let status = response.status().as_u16();
309            let error = response.text().await.unwrap_or_default();
310            return Err(SdkError::Api {
311                status,
312                message: error,
313            });
314        }
315
316        Ok(())
317    }
318
319    /// Check for new jobs to bid on
320    async fn check_new_jobs(&self) -> Result<(), SdkError> {
321        let url = format!("{}/api/jobs?status=open&status=bidding", self.config.base_url);
322
323        let response = self.client
324            .get(&url)
325            .send()
326            .await?;
327
328        if !response.status().is_success() {
329            return Ok(());
330        }
331
332        let jobs_response: JobListResponse = response.json().await?;
333
334        for job in jobs_response.jobs {
335            let mut state = self.state.write().await;
336            if state.seen_jobs.insert(job.id.clone()) {
337                drop(state);
338
339                println!("📋 New job: {} (${}-${})", job.title, job.budget_min, job.budget_max);
340
341                // Let handler decide whether to bid
342                if let Some(bid_params) = self.handler.should_bid(&job).await {
343                    match self.submit_bid(&job.id, bid_params).await {
344                        Ok(_) => println!("✅ Bid submitted on: {}", job.title),
345                        Err(e) => eprintln!("❌ Failed to bid: {}", e),
346                    }
347                }
348            }
349        }
350
351        Ok(())
352    }
353
354    /// Submit a bid on a job
355    async fn submit_bid(&self, job_id: &str, params: BidParams) -> Result<(), SdkError> {
356        let url = format!("{}/api/jobs/{}/bids", self.config.base_url, job_id);
357
358        let response = self.client
359            .post(&url)
360            .header("X-API-Key", &self.config.api_key)
361            .json(&SubmitBidRequest {
362                bid_amount: params.amount,
363                proposal: params.proposal,
364                estimated_hours: params.estimated_hours,
365            })
366            .send()
367            .await?;
368
369        if !response.status().is_success() {
370            let status = response.status().as_u16();
371            let error = response.text().await.unwrap_or_default();
372            return Err(SdkError::Api {
373                status,
374                message: error,
375            });
376        }
377
378        Ok(())
379    }
380
381    /// Add a job to track (called when assigned)
382    pub async fn track_job(&self, job_id: &str) {
383        let mut state = self.state.write().await;
384        state.my_jobs.insert(job_id.to_string());
385    }
386}