Merge pull request #2134 from Preventnetworkhacking/feat/economic-agents-mvp
feat(economic): ZeroClaw Economic Agents - Phase 1 Foundation [CDV-20]
This commit is contained in:
commit
43e3e9b897
335
Cargo.lock
generated
335
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -8,6 +8,7 @@ pub use schema::{
|
||||
AgentConfig, AgentsIpcConfig, AuditConfig, AutonomyConfig, BrowserComputerUseConfig,
|
||||
BrowserConfig, BuiltinHooksConfig, ChannelsConfig, ClassificationRule, ComposioConfig, Config,
|
||||
CoordinationConfig, CostConfig, CronConfig, DelegateAgentConfig, DiscordConfig,
|
||||
EconomicConfig, EconomicTokenPricing,
|
||||
DockerRuntimeConfig, EmbeddingRouteConfig, EstopConfig, FeishuConfig, GatewayConfig,
|
||||
GroupReplyConfig, GroupReplyMode, HardwareConfig, HardwareTransport, HeartbeatConfig,
|
||||
HooksConfig, HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig,
|
||||
|
||||
@ -237,6 +237,11 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub cost: CostConfig,
|
||||
|
||||
/// Economic agent survival tracking (`[economic]`).
|
||||
/// Tracks balance, token costs, work income, and survival status.
|
||||
#[serde(default)]
|
||||
pub economic: EconomicConfig,
|
||||
|
||||
/// Peripheral board configuration for hardware integration (`[peripherals]`).
|
||||
#[serde(default)]
|
||||
pub peripherals: PeripheralsConfig,
|
||||
@ -1133,6 +1138,83 @@ pub struct PeripheralBoardConfig {
|
||||
pub baud: u32,
|
||||
}
|
||||
|
||||
// ── Economic Agent Config ─────────────────────────────────────────
|
||||
|
||||
/// Token pricing configuration for economic tracking.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct EconomicTokenPricing {
|
||||
/// Price per million input tokens (USD)
|
||||
#[serde(default = "default_input_price")]
|
||||
pub input_price_per_million: f64,
|
||||
/// Price per million output tokens (USD)
|
||||
#[serde(default = "default_output_price")]
|
||||
pub output_price_per_million: f64,
|
||||
}
|
||||
|
||||
fn default_input_price() -> f64 {
|
||||
3.0 // Claude Sonnet 4 input price
|
||||
}
|
||||
|
||||
fn default_output_price() -> f64 {
|
||||
15.0 // Claude Sonnet 4 output price
|
||||
}
|
||||
|
||||
impl Default for EconomicTokenPricing {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
input_price_per_million: default_input_price(),
|
||||
output_price_per_million: default_output_price(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Economic agent survival tracking configuration (`[economic]` section).
|
||||
///
|
||||
/// Implements the ClawWork economic model for AI agents, tracking
|
||||
/// balance, costs, income, and survival status.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct EconomicConfig {
|
||||
/// Enable economic tracking (default: false)
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Starting balance in USD (default: 1000.0)
|
||||
#[serde(default = "default_initial_balance")]
|
||||
pub initial_balance: f64,
|
||||
|
||||
/// Token pricing configuration
|
||||
#[serde(default)]
|
||||
pub token_pricing: EconomicTokenPricing,
|
||||
|
||||
/// Minimum evaluation score (0.0-1.0) to receive payment (default: 0.6)
|
||||
#[serde(default = "default_min_evaluation_threshold")]
|
||||
pub min_evaluation_threshold: f64,
|
||||
|
||||
/// Data directory for economic state persistence (relative to workspace)
|
||||
#[serde(default)]
|
||||
pub data_path: Option<String>,
|
||||
}
|
||||
|
||||
fn default_initial_balance() -> f64 {
|
||||
1000.0
|
||||
}
|
||||
|
||||
fn default_min_evaluation_threshold() -> f64 {
|
||||
0.6
|
||||
}
|
||||
|
||||
impl Default for EconomicConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
initial_balance: default_initial_balance(),
|
||||
token_pricing: EconomicTokenPricing::default(),
|
||||
min_evaluation_threshold: default_min_evaluation_threshold(),
|
||||
data_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_peripheral_transport() -> String {
|
||||
"serial".into()
|
||||
}
|
||||
@ -5116,6 +5198,7 @@ impl Default for Config {
|
||||
proxy: ProxyConfig::default(),
|
||||
identity: IdentityConfig::default(),
|
||||
cost: CostConfig::default(),
|
||||
economic: EconomicConfig::default(),
|
||||
peripherals: PeripheralsConfig::default(),
|
||||
agents: HashMap::new(),
|
||||
coordination: CoordinationConfig::default(),
|
||||
@ -7747,6 +7830,7 @@ default_temperature = 0.7
|
||||
agent: AgentConfig::default(),
|
||||
identity: IdentityConfig::default(),
|
||||
cost: CostConfig::default(),
|
||||
economic: EconomicConfig::default(),
|
||||
peripherals: PeripheralsConfig::default(),
|
||||
agents: HashMap::new(),
|
||||
hooks: HooksConfig::default(),
|
||||
@ -8118,6 +8202,7 @@ tool_dispatcher = "xml"
|
||||
agent: AgentConfig::default(),
|
||||
identity: IdentityConfig::default(),
|
||||
cost: CostConfig::default(),
|
||||
economic: EconomicConfig::default(),
|
||||
peripherals: PeripheralsConfig::default(),
|
||||
agents: HashMap::new(),
|
||||
hooks: HooksConfig::default(),
|
||||
|
||||
724
src/economic/classifier.rs
Normal file
724
src/economic/classifier.rs
Normal file
@ -0,0 +1,724 @@
|
||||
//! Task Classifier for ZeroClaw Economic Agents
|
||||
//!
|
||||
//! Classifies work instructions into 44 BLS occupations with wage data
|
||||
//! to estimate task value for agent economics.
|
||||
//!
|
||||
//! ## Overview
|
||||
//!
|
||||
//! The classifier matches task instructions to standardized occupation
|
||||
//! categories using keyword matching and heuristics, then calculates
|
||||
//! expected payment based on BLS hourly wage data.
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use zeroclaw::economic::classifier::{TaskClassifier, OccupationCategory};
|
||||
//!
|
||||
//! let classifier = TaskClassifier::new();
|
||||
//! let result = classifier.classify("Write a REST API in Rust").await?;
|
||||
//!
|
||||
//! println!("Occupation: {}", result.occupation);
|
||||
//! println!("Hourly wage: ${:.2}", result.hourly_wage);
|
||||
//! println!("Estimated hours: {:.2}", result.estimated_hours);
|
||||
//! println!("Max payment: ${:.2}", result.max_payment);
|
||||
//! ```
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Occupation category groupings based on BLS major groups
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum OccupationCategory {
|
||||
/// Software, IT, engineering roles
|
||||
TechnologyEngineering,
|
||||
/// Finance, accounting, management, sales
|
||||
BusinessFinance,
|
||||
/// Medical, nursing, social work
|
||||
HealthcareSocialServices,
|
||||
/// Legal, media, operations, other professional
|
||||
LegalMediaOperations,
|
||||
}
|
||||
|
||||
impl OccupationCategory {
|
||||
/// Returns a human-readable name for the category
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::TechnologyEngineering => "Technology & Engineering",
|
||||
Self::BusinessFinance => "Business & Finance",
|
||||
Self::HealthcareSocialServices => "Healthcare & Social Services",
|
||||
Self::LegalMediaOperations => "Legal, Media & Operations",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single occupation with BLS wage data
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Occupation {
|
||||
/// Official BLS occupation name
|
||||
pub name: String,
|
||||
/// Hourly wage in USD (BLS median)
|
||||
pub hourly_wage: f64,
|
||||
/// Category grouping
|
||||
pub category: OccupationCategory,
|
||||
/// Keywords for matching
|
||||
#[serde(skip)]
|
||||
pub keywords: Vec<&'static str>,
|
||||
}
|
||||
|
||||
/// Result of classifying a task instruction
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClassificationResult {
|
||||
/// Matched occupation name
|
||||
pub occupation: String,
|
||||
/// BLS hourly wage for this occupation
|
||||
pub hourly_wage: f64,
|
||||
/// Estimated hours to complete task
|
||||
pub estimated_hours: f64,
|
||||
/// Maximum payment (hours × wage)
|
||||
pub max_payment: f64,
|
||||
/// Classification confidence (0.0 - 1.0)
|
||||
pub confidence: f64,
|
||||
/// Category of the matched occupation
|
||||
pub category: OccupationCategory,
|
||||
/// Brief reasoning for the classification
|
||||
pub reasoning: String,
|
||||
}
|
||||
|
||||
/// Task classifier that maps instructions to BLS occupations
|
||||
#[derive(Debug)]
|
||||
pub struct TaskClassifier {
|
||||
occupations: Vec<Occupation>,
|
||||
keyword_index: HashMap<&'static str, Vec<usize>>,
|
||||
fallback_occupation: String,
|
||||
fallback_wage: f64,
|
||||
}
|
||||
|
||||
impl Default for TaskClassifier {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskClassifier {
|
||||
/// Create a new TaskClassifier with embedded BLS occupation data
|
||||
pub fn new() -> Self {
|
||||
let occupations = Self::load_occupations();
|
||||
let keyword_index = Self::build_keyword_index(&occupations);
|
||||
|
||||
Self {
|
||||
occupations,
|
||||
keyword_index,
|
||||
fallback_occupation: "General and Operations Managers".to_string(),
|
||||
fallback_wage: 64.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load all 44 BLS occupations with wage data
|
||||
fn load_occupations() -> Vec<Occupation> {
|
||||
use OccupationCategory::*;
|
||||
|
||||
vec![
|
||||
// Technology & Engineering
|
||||
Occupation {
|
||||
name: "Software Developers".into(),
|
||||
hourly_wage: 69.50,
|
||||
category: TechnologyEngineering,
|
||||
keywords: vec![
|
||||
"software", "code", "programming", "developer", "rust", "python",
|
||||
"javascript", "api", "backend", "frontend", "fullstack", "app",
|
||||
"application", "debug", "refactor", "implement", "algorithm",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Computer and Information Systems Managers".into(),
|
||||
hourly_wage: 90.38,
|
||||
category: TechnologyEngineering,
|
||||
keywords: vec![
|
||||
"it manager", "cto", "tech lead", "infrastructure", "systems",
|
||||
"devops", "cloud", "architecture", "platform", "enterprise",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Industrial Engineers".into(),
|
||||
hourly_wage: 51.87,
|
||||
category: TechnologyEngineering,
|
||||
keywords: vec![
|
||||
"industrial", "process", "optimization", "efficiency", "workflow",
|
||||
"manufacturing", "lean", "six sigma", "production",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Mechanical Engineers".into(),
|
||||
hourly_wage: 52.92,
|
||||
category: TechnologyEngineering,
|
||||
keywords: vec![
|
||||
"mechanical", "cad", "solidworks", "machinery", "thermal",
|
||||
"hvac", "automotive", "robotics",
|
||||
],
|
||||
},
|
||||
// Business & Finance
|
||||
Occupation {
|
||||
name: "Accountants and Auditors".into(),
|
||||
hourly_wage: 44.96,
|
||||
category: BusinessFinance,
|
||||
keywords: vec![
|
||||
"accounting", "audit", "tax", "bookkeeping", "financial statements",
|
||||
"gaap", "ledger", "reconciliation", "cpa",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Administrative Services Managers".into(),
|
||||
hourly_wage: 60.59,
|
||||
category: BusinessFinance,
|
||||
keywords: vec![
|
||||
"administrative", "office manager", "facilities", "operations",
|
||||
"scheduling", "coordination",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Buyers and Purchasing Agents".into(),
|
||||
hourly_wage: 39.29,
|
||||
category: BusinessFinance,
|
||||
keywords: vec![
|
||||
"procurement", "purchasing", "vendor", "supplier", "sourcing",
|
||||
"negotiation", "contracts",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Compliance Officers".into(),
|
||||
hourly_wage: 40.86,
|
||||
category: BusinessFinance,
|
||||
keywords: vec![
|
||||
"compliance", "regulatory", "audit", "policy", "governance",
|
||||
"risk", "sox", "gdpr",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Financial Managers".into(),
|
||||
hourly_wage: 86.76,
|
||||
category: BusinessFinance,
|
||||
keywords: vec![
|
||||
"cfo", "finance director", "treasury", "budget", "financial planning",
|
||||
"investment management",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Financial and Investment Analysts".into(),
|
||||
hourly_wage: 56.01,
|
||||
category: BusinessFinance,
|
||||
keywords: vec![
|
||||
"financial analysis", "investment", "portfolio", "stock", "equity",
|
||||
"valuation", "modeling", "dcf", "market research",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "General and Operations Managers".into(),
|
||||
hourly_wage: 64.00,
|
||||
category: BusinessFinance,
|
||||
keywords: vec![
|
||||
"operations", "general manager", "director", "oversee", "manage",
|
||||
"strategy", "leadership", "business",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Market Research Analysts and Marketing Specialists".into(),
|
||||
hourly_wage: 41.58,
|
||||
category: BusinessFinance,
|
||||
keywords: vec![
|
||||
"market research", "marketing", "campaign", "branding", "seo",
|
||||
"advertising", "analytics", "customer", "segment",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Personal Financial Advisors".into(),
|
||||
hourly_wage: 77.02,
|
||||
category: BusinessFinance,
|
||||
keywords: vec![
|
||||
"financial advisor", "wealth", "retirement", "401k", "ira",
|
||||
"estate planning", "insurance",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Project Management Specialists".into(),
|
||||
hourly_wage: 51.97,
|
||||
category: BusinessFinance,
|
||||
keywords: vec![
|
||||
"project manager", "pmp", "agile", "scrum", "sprint", "milestone",
|
||||
"timeline", "stakeholder", "deliverable",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Property, Real Estate, and Community Association Managers".into(),
|
||||
hourly_wage: 39.77,
|
||||
category: BusinessFinance,
|
||||
keywords: vec![
|
||||
"property", "real estate", "landlord", "tenant", "lease",
|
||||
"hoa", "community",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Sales Managers".into(),
|
||||
hourly_wage: 77.37,
|
||||
category: BusinessFinance,
|
||||
keywords: vec![
|
||||
"sales manager", "revenue", "quota", "pipeline", "crm",
|
||||
"account executive", "territory",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Marketing and Sales Managers".into(),
|
||||
hourly_wage: 79.35,
|
||||
category: BusinessFinance,
|
||||
keywords: vec![
|
||||
"vp sales", "cmo", "growth", "go-to-market", "demand gen",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Financial Specialists".into(),
|
||||
hourly_wage: 48.12,
|
||||
category: BusinessFinance,
|
||||
keywords: vec![
|
||||
"financial specialist", "credit", "loan", "underwriting",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Securities, Commodities, and Financial Services Sales Agents".into(),
|
||||
hourly_wage: 48.12,
|
||||
category: BusinessFinance,
|
||||
keywords: vec![
|
||||
"broker", "securities", "commodities", "trading", "series 7",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Business Operations Specialists, All Other".into(),
|
||||
hourly_wage: 44.41,
|
||||
category: BusinessFinance,
|
||||
keywords: vec![
|
||||
"business analyst", "operations specialist", "process improvement",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Claims Adjusters, Examiners, and Investigators".into(),
|
||||
hourly_wage: 37.87,
|
||||
category: BusinessFinance,
|
||||
keywords: vec![
|
||||
"claims", "insurance", "adjuster", "investigator", "fraud",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Transportation, Storage, and Distribution Managers".into(),
|
||||
hourly_wage: 55.77,
|
||||
category: BusinessFinance,
|
||||
keywords: vec![
|
||||
"logistics", "supply chain", "warehouse", "distribution", "shipping",
|
||||
"inventory", "fulfillment",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Industrial Production Managers".into(),
|
||||
hourly_wage: 62.11,
|
||||
category: BusinessFinance,
|
||||
keywords: vec![
|
||||
"production manager", "plant manager", "manufacturing operations",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Lodging Managers".into(),
|
||||
hourly_wage: 37.24,
|
||||
category: BusinessFinance,
|
||||
keywords: vec![
|
||||
"hotel", "hospitality", "lodging", "resort", "concierge",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Real Estate Brokers".into(),
|
||||
hourly_wage: 39.77,
|
||||
category: BusinessFinance,
|
||||
keywords: vec![
|
||||
"real estate broker", "realtor", "mls", "listing",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Managers, All Other".into(),
|
||||
hourly_wage: 72.06,
|
||||
category: BusinessFinance,
|
||||
keywords: vec!["manager", "supervisor", "team lead"],
|
||||
},
|
||||
// Healthcare & Social Services
|
||||
Occupation {
|
||||
name: "Medical and Health Services Managers".into(),
|
||||
hourly_wage: 66.22,
|
||||
category: HealthcareSocialServices,
|
||||
keywords: vec![
|
||||
"healthcare", "hospital", "clinic", "medical", "health services",
|
||||
"patient", "hipaa",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Social and Community Service Managers".into(),
|
||||
hourly_wage: 41.39,
|
||||
category: HealthcareSocialServices,
|
||||
keywords: vec![
|
||||
"social services", "community", "nonprofit", "outreach",
|
||||
"case management", "welfare",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Child, Family, and School Social Workers".into(),
|
||||
hourly_wage: 41.39,
|
||||
category: HealthcareSocialServices,
|
||||
keywords: vec![
|
||||
"social worker", "child welfare", "family services", "school counselor",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Registered Nurses".into(),
|
||||
hourly_wage: 66.22,
|
||||
category: HealthcareSocialServices,
|
||||
keywords: vec!["nurse", "rn", "nursing", "patient care", "clinical"],
|
||||
},
|
||||
Occupation {
|
||||
name: "Nurse Practitioners".into(),
|
||||
hourly_wage: 66.22,
|
||||
category: HealthcareSocialServices,
|
||||
keywords: vec!["np", "nurse practitioner", "aprn", "prescribe"],
|
||||
},
|
||||
Occupation {
|
||||
name: "Pharmacists".into(),
|
||||
hourly_wage: 66.22,
|
||||
category: HealthcareSocialServices,
|
||||
keywords: vec!["pharmacy", "pharmacist", "medication", "prescription", "drug"],
|
||||
},
|
||||
Occupation {
|
||||
name: "Medical Secretaries and Administrative Assistants".into(),
|
||||
hourly_wage: 66.22,
|
||||
category: HealthcareSocialServices,
|
||||
keywords: vec![
|
||||
"medical secretary", "medical records", "ehr", "scheduling appointments",
|
||||
],
|
||||
},
|
||||
// Legal, Media & Operations
|
||||
Occupation {
|
||||
name: "Lawyers".into(),
|
||||
hourly_wage: 44.41,
|
||||
category: LegalMediaOperations,
|
||||
keywords: vec![
|
||||
"lawyer", "attorney", "legal", "contract", "litigation",
|
||||
"counsel", "law", "paralegal",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Editors".into(),
|
||||
hourly_wage: 72.06,
|
||||
category: LegalMediaOperations,
|
||||
keywords: vec![
|
||||
"editor", "editing", "proofread", "copy edit", "manuscript",
|
||||
"publication",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Film and Video Editors".into(),
|
||||
hourly_wage: 68.15,
|
||||
category: LegalMediaOperations,
|
||||
keywords: vec![
|
||||
"video editor", "film", "premiere", "final cut", "davinci",
|
||||
"post-production",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Audio and Video Technicians".into(),
|
||||
hourly_wage: 41.86,
|
||||
category: LegalMediaOperations,
|
||||
keywords: vec![
|
||||
"audio", "video", "av", "broadcast", "streaming", "recording",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Producers and Directors".into(),
|
||||
hourly_wage: 41.86,
|
||||
category: LegalMediaOperations,
|
||||
keywords: vec![
|
||||
"producer", "director", "production", "creative director",
|
||||
"content", "show",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "News Analysts, Reporters, and Journalists".into(),
|
||||
hourly_wage: 68.15,
|
||||
category: LegalMediaOperations,
|
||||
keywords: vec![
|
||||
"journalist", "reporter", "news", "article", "press",
|
||||
"interview", "story",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Entertainment and Recreation Managers, Except Gambling".into(),
|
||||
hourly_wage: 41.86,
|
||||
category: LegalMediaOperations,
|
||||
keywords: vec![
|
||||
"entertainment", "recreation", "event", "venue", "concert",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Recreation Workers".into(),
|
||||
hourly_wage: 41.86,
|
||||
category: LegalMediaOperations,
|
||||
keywords: vec!["recreation", "activity", "fitness", "sports"],
|
||||
},
|
||||
Occupation {
|
||||
name: "Customer Service Representatives".into(),
|
||||
hourly_wage: 44.41,
|
||||
category: LegalMediaOperations,
|
||||
keywords: vec![
|
||||
"customer service", "support", "helpdesk", "ticket", "chat",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "Private Detectives and Investigators".into(),
|
||||
hourly_wage: 37.87,
|
||||
category: LegalMediaOperations,
|
||||
keywords: vec![
|
||||
"detective", "investigator", "background check", "surveillance",
|
||||
],
|
||||
},
|
||||
Occupation {
|
||||
name: "First-Line Supervisors of Police and Detectives".into(),
|
||||
hourly_wage: 72.06,
|
||||
category: LegalMediaOperations,
|
||||
keywords: vec!["police", "law enforcement", "security supervisor"],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Build keyword → occupation index for fast lookup
|
||||
fn build_keyword_index(occupations: &[Occupation]) -> HashMap<&'static str, Vec<usize>> {
|
||||
let mut index: HashMap<&'static str, Vec<usize>> = HashMap::new();
|
||||
for (i, occ) in occupations.iter().enumerate() {
|
||||
for &kw in &occ.keywords {
|
||||
index.entry(kw).or_default().push(i);
|
||||
}
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
/// Classify a task instruction into an occupation with estimated value
|
||||
///
|
||||
/// This is a synchronous keyword-based classifier. For LLM-based
|
||||
/// classification, use `classify_with_llm` instead.
|
||||
pub fn classify(&self, instruction: &str) -> ClassificationResult {
|
||||
let lower = instruction.to_lowercase();
|
||||
let mut scores: HashMap<usize, f64> = HashMap::new();
|
||||
|
||||
// Score each occupation by keyword matches
|
||||
for (keyword, occ_indices) in &self.keyword_index {
|
||||
if lower.contains(keyword) {
|
||||
for &idx in occ_indices {
|
||||
*scores.entry(idx).or_default() += 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find best match
|
||||
let (best_idx, best_score) = scores
|
||||
.iter()
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
||||
.map(|(&idx, &score)| (idx, score))
|
||||
.unwrap_or((usize::MAX, 0.0));
|
||||
|
||||
let (occupation, hourly_wage, category, confidence, reasoning) = if best_idx < self.occupations.len() {
|
||||
let occ = &self.occupations[best_idx];
|
||||
let confidence = (best_score / 3.0).min(1.0); // Normalize confidence
|
||||
(
|
||||
occ.name.clone(),
|
||||
occ.hourly_wage,
|
||||
occ.category,
|
||||
confidence,
|
||||
format!("Matched {} keywords", best_score as i32),
|
||||
)
|
||||
} else {
|
||||
// Fallback
|
||||
(
|
||||
self.fallback_occupation.clone(),
|
||||
self.fallback_wage,
|
||||
OccupationCategory::BusinessFinance,
|
||||
0.3,
|
||||
"Fallback classification - no strong keyword match".to_string(),
|
||||
)
|
||||
};
|
||||
|
||||
let estimated_hours = Self::estimate_hours(instruction);
|
||||
let max_payment = (estimated_hours * hourly_wage * 100.0).round() / 100.0;
|
||||
|
||||
ClassificationResult {
|
||||
occupation,
|
||||
hourly_wage,
|
||||
estimated_hours,
|
||||
max_payment,
|
||||
confidence,
|
||||
category,
|
||||
reasoning,
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimate hours based on instruction complexity
|
||||
fn estimate_hours(instruction: &str) -> f64 {
|
||||
let word_count = instruction.split_whitespace().count();
|
||||
let has_complex_markers = instruction.to_lowercase().contains("implement")
|
||||
|| instruction.contains("build")
|
||||
|| instruction.contains("create")
|
||||
|| instruction.contains("design")
|
||||
|| instruction.contains("develop");
|
||||
|
||||
let has_simple_markers = instruction.to_lowercase().contains("fix")
|
||||
|| instruction.contains("update")
|
||||
|| instruction.contains("change")
|
||||
|| instruction.contains("review");
|
||||
|
||||
let base_hours = if has_complex_markers {
|
||||
2.0
|
||||
} else if has_simple_markers {
|
||||
0.5
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
// Scale by instruction length
|
||||
let length_factor = (word_count as f64 / 20.0).max(0.5).min(2.0);
|
||||
let hours = base_hours * length_factor;
|
||||
|
||||
// Clamp to valid range
|
||||
hours.max(0.25).min(40.0)
|
||||
}
|
||||
|
||||
/// Get all occupations
|
||||
pub fn occupations(&self) -> &[Occupation] {
|
||||
&self.occupations
|
||||
}
|
||||
|
||||
/// Get occupations by category
|
||||
pub fn occupations_by_category(&self, category: OccupationCategory) -> Vec<&Occupation> {
|
||||
self.occupations
|
||||
.iter()
|
||||
.filter(|o| o.category == category)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get the fallback occupation name
|
||||
pub fn fallback_occupation(&self) -> &str {
|
||||
&self.fallback_occupation
|
||||
}
|
||||
|
||||
/// Get the fallback hourly wage
|
||||
pub fn fallback_wage(&self) -> f64 {
|
||||
self.fallback_wage
|
||||
}
|
||||
|
||||
/// Look up an occupation by exact name
|
||||
pub fn get_occupation(&self, name: &str) -> Option<&Occupation> {
|
||||
self.occupations.iter().find(|o| o.name == name)
|
||||
}
|
||||
|
||||
/// Fuzzy match an occupation name (case-insensitive, substring)
|
||||
pub fn fuzzy_match(&self, name: &str) -> Option<&Occupation> {
|
||||
let lower = name.to_lowercase();
|
||||
|
||||
// Exact match first
|
||||
if let Some(occ) = self.occupations.iter().find(|o| o.name == name) {
|
||||
return Some(occ);
|
||||
}
|
||||
|
||||
// Case-insensitive match
|
||||
if let Some(occ) = self
|
||||
.occupations
|
||||
.iter()
|
||||
.find(|o| o.name.to_lowercase() == lower)
|
||||
{
|
||||
return Some(occ);
|
||||
}
|
||||
|
||||
// Substring match
|
||||
self.occupations
|
||||
.iter()
|
||||
.find(|o| lower.contains(&o.name.to_lowercase()) || o.name.to_lowercase().contains(&lower))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_classifier_new() {
|
||||
let classifier = TaskClassifier::new();
|
||||
assert_eq!(classifier.occupations.len(), 44);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_software() {
|
||||
let classifier = TaskClassifier::new();
|
||||
let result = classifier.classify("Write a REST API in Rust with authentication");
|
||||
|
||||
assert_eq!(result.occupation, "Software Developers");
|
||||
assert!((result.hourly_wage - 69.50).abs() < 0.01);
|
||||
assert!(result.confidence > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_finance() {
|
||||
let classifier = TaskClassifier::new();
|
||||
let result = classifier.classify("Prepare quarterly financial statements and audit trail");
|
||||
|
||||
assert!(
|
||||
result.occupation.contains("Account")
|
||||
|| result.occupation.contains("Financial"),
|
||||
"Expected finance occupation, got: {}",
|
||||
result.occupation
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_fallback() {
|
||||
let classifier = TaskClassifier::new();
|
||||
let result = classifier.classify("xyzzy foobar baz");
|
||||
|
||||
assert_eq!(result.occupation, "General and Operations Managers");
|
||||
assert_eq!(result.confidence, 0.3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_estimate_hours_complex() {
|
||||
let hours = TaskClassifier::estimate_hours(
|
||||
"Implement a complete microservices architecture with event sourcing",
|
||||
);
|
||||
assert!(hours >= 1.0, "Complex task should estimate >= 1 hour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_estimate_hours_simple() {
|
||||
let hours = TaskClassifier::estimate_hours("Fix typo");
|
||||
assert!(hours <= 1.0, "Simple task should estimate <= 1 hour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzzy_match() {
|
||||
let classifier = TaskClassifier::new();
|
||||
|
||||
// Exact match
|
||||
assert!(classifier.fuzzy_match("Software Developers").is_some());
|
||||
|
||||
// Case insensitive
|
||||
assert!(classifier.fuzzy_match("software developers").is_some());
|
||||
|
||||
// Substring
|
||||
assert!(classifier.fuzzy_match("Software").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_occupations_by_category() {
|
||||
let classifier = TaskClassifier::new();
|
||||
let tech = classifier.occupations_by_category(OccupationCategory::TechnologyEngineering);
|
||||
|
||||
assert!(!tech.is_empty());
|
||||
assert!(tech.iter().any(|o| o.name == "Software Developers"));
|
||||
}
|
||||
}
|
||||
369
src/economic/costs.rs
Normal file
369
src/economic/costs.rs
Normal file
@ -0,0 +1,369 @@
|
||||
//! Token cost tracking types for economic agents.
|
||||
//!
|
||||
//! Separates costs by channel (LLM, search API, OCR, etc.) following
|
||||
//! the ClawWork economic model.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Channel-separated cost breakdown for a task or session.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct CostBreakdown {
|
||||
/// Cost from LLM token usage
|
||||
pub llm_tokens: f64,
|
||||
/// Cost from search API calls (Brave, JINA, Tavily, etc.)
|
||||
pub search_api: f64,
|
||||
/// Cost from OCR API calls
|
||||
pub ocr_api: f64,
|
||||
/// Cost from other API calls
|
||||
pub other_api: f64,
|
||||
}
|
||||
|
||||
impl CostBreakdown {
|
||||
/// Create a new empty cost breakdown.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Get total cost across all channels.
|
||||
pub fn total(&self) -> f64 {
|
||||
self.llm_tokens + self.search_api + self.ocr_api + self.other_api
|
||||
}
|
||||
|
||||
/// Add another breakdown to this one.
|
||||
pub fn add(&mut self, other: &CostBreakdown) {
|
||||
self.llm_tokens += other.llm_tokens;
|
||||
self.search_api += other.search_api;
|
||||
self.ocr_api += other.ocr_api;
|
||||
self.other_api += other.other_api;
|
||||
}
|
||||
|
||||
/// Reset all costs to zero.
|
||||
pub fn reset(&mut self) {
|
||||
self.llm_tokens = 0.0;
|
||||
self.search_api = 0.0;
|
||||
self.ocr_api = 0.0;
|
||||
self.other_api = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Token pricing configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TokenPricing {
|
||||
/// Price per million input tokens (USD)
|
||||
pub input_price_per_million: f64,
|
||||
/// Price per million output tokens (USD)
|
||||
pub output_price_per_million: f64,
|
||||
}
|
||||
|
||||
impl Default for TokenPricing {
|
||||
fn default() -> Self {
|
||||
// Default to Claude Sonnet 4 pricing via OpenRouter
|
||||
Self {
|
||||
input_price_per_million: 3.0,
|
||||
output_price_per_million: 15.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TokenPricing {
|
||||
/// Calculate cost for given token counts.
|
||||
pub fn calculate_cost(&self, input_tokens: u64, output_tokens: u64) -> f64 {
|
||||
let input_cost = (input_tokens as f64 / 1_000_000.0) * self.input_price_per_million;
|
||||
let output_cost = (output_tokens as f64 / 1_000_000.0) * self.output_price_per_million;
|
||||
input_cost + output_cost
|
||||
}
|
||||
}
|
||||
|
||||
/// A single LLM call record with token details.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LlmCallRecord {
|
||||
/// Timestamp of the call
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// API name/source (e.g., "agent", "wrapup", "research")
|
||||
pub api_name: String,
|
||||
/// Number of input tokens
|
||||
pub input_tokens: u64,
|
||||
/// Number of output tokens
|
||||
pub output_tokens: u64,
|
||||
/// Cost in USD
|
||||
pub cost: f64,
|
||||
}
|
||||
|
||||
/// A single API call record (non-LLM).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ApiCallRecord {
|
||||
/// Timestamp of the call
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// API name (e.g., "tavily_search", "jina_reader")
|
||||
pub api_name: String,
|
||||
/// Pricing model used
|
||||
pub pricing_model: PricingModel,
|
||||
/// Number of tokens (if token-based pricing)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tokens: Option<u64>,
|
||||
/// Price per million tokens (if token-based)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub price_per_million: Option<f64>,
|
||||
/// Cost in USD
|
||||
pub cost: f64,
|
||||
}
|
||||
|
||||
/// Pricing model for API calls.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PricingModel {
|
||||
/// Token-based pricing (cost = tokens / 1M * price_per_million)
|
||||
PerToken,
|
||||
/// Flat rate per call
|
||||
FlatRate,
|
||||
}
|
||||
|
||||
/// Comprehensive task cost record (one per task).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TaskCostRecord {
|
||||
/// Task end timestamp
|
||||
pub timestamp_end: DateTime<Utc>,
|
||||
/// Task start timestamp
|
||||
pub timestamp_start: DateTime<Utc>,
|
||||
/// Date the task was assigned (YYYY-MM-DD)
|
||||
pub date: String,
|
||||
/// Unique task identifier
|
||||
pub task_id: String,
|
||||
/// LLM usage summary
|
||||
pub llm_usage: LlmUsageSummary,
|
||||
/// API usage summary
|
||||
pub api_usage: ApiUsageSummary,
|
||||
/// Cost summary by channel
|
||||
pub cost_summary: CostBreakdown,
|
||||
/// Balance after this task
|
||||
pub balance_after: f64,
|
||||
/// Session cost so far
|
||||
pub session_cost: f64,
|
||||
/// Daily cost so far
|
||||
pub daily_cost: f64,
|
||||
}
|
||||
|
||||
/// Aggregated LLM usage for a task.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct LlmUsageSummary {
|
||||
/// Number of LLM calls made
|
||||
pub total_calls: usize,
|
||||
/// Total input tokens
|
||||
pub total_input_tokens: u64,
|
||||
/// Total output tokens
|
||||
pub total_output_tokens: u64,
|
||||
/// Total tokens (input + output)
|
||||
pub total_tokens: u64,
|
||||
/// Total cost in USD
|
||||
pub total_cost: f64,
|
||||
/// Pricing used
|
||||
pub input_price_per_million: f64,
|
||||
pub output_price_per_million: f64,
|
||||
/// Detailed call records
|
||||
#[serde(default)]
|
||||
pub calls_detail: Vec<LlmCallRecord>,
|
||||
}
|
||||
|
||||
/// Aggregated API usage for a task.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ApiUsageSummary {
|
||||
/// Number of API calls made
|
||||
pub total_calls: usize,
|
||||
/// Search API costs
|
||||
pub search_api_cost: f64,
|
||||
/// OCR API costs
|
||||
pub ocr_api_cost: f64,
|
||||
/// Other API costs
|
||||
pub other_api_cost: f64,
|
||||
/// Number of token-based calls
|
||||
pub token_based_calls: usize,
|
||||
/// Number of flat-rate calls
|
||||
pub flat_rate_calls: usize,
|
||||
/// Detailed call records
|
||||
#[serde(default)]
|
||||
pub calls_detail: Vec<ApiCallRecord>,
|
||||
}
|
||||
|
||||
/// Work income record.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkIncomeRecord {
|
||||
/// Timestamp
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// Date (YYYY-MM-DD)
|
||||
pub date: String,
|
||||
/// Task identifier
|
||||
pub task_id: String,
|
||||
/// Base payment amount offered
|
||||
pub base_amount: f64,
|
||||
/// Actual payment received (0 if below threshold)
|
||||
pub actual_payment: f64,
|
||||
/// Evaluation score (0.0-1.0)
|
||||
pub evaluation_score: f64,
|
||||
/// Minimum threshold required for payment
|
||||
pub threshold: f64,
|
||||
/// Whether payment was awarded
|
||||
pub payment_awarded: bool,
|
||||
/// Optional description
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Balance after this income
|
||||
pub balance_after: f64,
|
||||
}
|
||||
|
||||
/// Daily balance record for persistence.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BalanceRecord {
|
||||
/// Date (YYYY-MM-DD or "initialization")
|
||||
pub date: String,
|
||||
/// Current balance
|
||||
pub balance: f64,
|
||||
/// Token cost delta for this period
|
||||
pub token_cost_delta: f64,
|
||||
/// Work income delta for this period
|
||||
pub work_income_delta: f64,
|
||||
/// Trading profit delta for this period
|
||||
pub trading_profit_delta: f64,
|
||||
/// Cumulative total token cost
|
||||
pub total_token_cost: f64,
|
||||
/// Cumulative total work income
|
||||
pub total_work_income: f64,
|
||||
/// Cumulative total trading profit
|
||||
pub total_trading_profit: f64,
|
||||
/// Net worth (balance + portfolio value)
|
||||
pub net_worth: f64,
|
||||
/// Current survival status
|
||||
pub survival_status: String,
|
||||
/// Tasks completed in this period
|
||||
#[serde(default)]
|
||||
pub completed_tasks: Vec<String>,
|
||||
/// Primary task ID for the day
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub task_id: Option<String>,
|
||||
/// Time to complete tasks (seconds)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub task_completion_time_seconds: Option<f64>,
|
||||
/// Whether session was aborted by API error
|
||||
#[serde(default)]
|
||||
pub api_error: bool,
|
||||
}
|
||||
|
||||
/// Task completion record for analytics.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TaskCompletionRecord {
|
||||
/// Task identifier
|
||||
pub task_id: String,
|
||||
/// Date (YYYY-MM-DD)
|
||||
pub date: String,
|
||||
/// Attempt number (1-based)
|
||||
pub attempt: u32,
|
||||
/// Whether work was submitted
|
||||
pub work_submitted: bool,
|
||||
/// Evaluation score (0.0-1.0)
|
||||
pub evaluation_score: f64,
|
||||
/// Money earned from this task
|
||||
pub money_earned: f64,
|
||||
/// Wall-clock time in seconds
|
||||
pub wall_clock_seconds: f64,
|
||||
/// Timestamp of completion
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Economic analytics summary.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct EconomicAnalytics {
|
||||
/// Total costs by channel
|
||||
pub total_costs: CostBreakdown,
|
||||
/// Costs broken down by date
|
||||
pub by_date: HashMap<String, DateCostSummary>,
|
||||
/// Costs broken down by task
|
||||
pub by_task: HashMap<String, TaskCostSummary>,
|
||||
/// Total number of tasks
|
||||
pub total_tasks: usize,
|
||||
/// Total income earned
|
||||
pub total_income: f64,
|
||||
/// Number of tasks that received payment
|
||||
pub tasks_paid: usize,
|
||||
/// Number of tasks rejected (below threshold)
|
||||
pub tasks_rejected: usize,
|
||||
}
|
||||
|
||||
/// Cost summary for a single date.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct DateCostSummary {
|
||||
/// Costs by channel
|
||||
#[serde(flatten)]
|
||||
pub costs: CostBreakdown,
|
||||
/// Total cost
|
||||
pub total: f64,
|
||||
/// Income earned
|
||||
pub income: f64,
|
||||
}
|
||||
|
||||
/// Cost summary for a single task.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TaskCostSummary {
|
||||
/// Costs by channel
|
||||
#[serde(flatten)]
|
||||
pub costs: CostBreakdown,
|
||||
/// Total cost
|
||||
pub total: f64,
|
||||
/// Date of the task
|
||||
pub date: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cost_breakdown_total() {
|
||||
let breakdown = CostBreakdown {
|
||||
llm_tokens: 1.0,
|
||||
search_api: 0.5,
|
||||
ocr_api: 0.25,
|
||||
other_api: 0.1,
|
||||
};
|
||||
assert!((breakdown.total() - 1.85).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_breakdown_add() {
|
||||
let mut a = CostBreakdown {
|
||||
llm_tokens: 1.0,
|
||||
search_api: 0.5,
|
||||
ocr_api: 0.0,
|
||||
other_api: 0.0,
|
||||
};
|
||||
let b = CostBreakdown {
|
||||
llm_tokens: 0.5,
|
||||
search_api: 0.25,
|
||||
ocr_api: 0.1,
|
||||
other_api: 0.05,
|
||||
};
|
||||
a.add(&b);
|
||||
assert!((a.llm_tokens - 1.5).abs() < f64::EPSILON);
|
||||
assert!((a.search_api - 0.75).abs() < f64::EPSILON);
|
||||
assert!((a.total() - 2.4).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_pricing_calculation() {
|
||||
let pricing = TokenPricing {
|
||||
input_price_per_million: 3.0,
|
||||
output_price_per_million: 15.0,
|
||||
};
|
||||
// 1000 input, 500 output
|
||||
// (1000/1M)*3 + (500/1M)*15 = 0.003 + 0.0075 = 0.0105
|
||||
let cost = pricing.calculate_cost(1000, 500);
|
||||
assert!((cost - 0.0105).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_token_pricing() {
|
||||
let pricing = TokenPricing::default();
|
||||
assert!((pricing.input_price_per_million - 3.0).abs() < f64::EPSILON);
|
||||
assert!((pricing.output_price_per_million - 15.0).abs() < f64::EPSILON);
|
||||
}
|
||||
}
|
||||
85
src/economic/mod.rs
Normal file
85
src/economic/mod.rs
Normal file
@ -0,0 +1,85 @@
|
||||
//! Economic tracking module for agent survival economics.
|
||||
//!
|
||||
//! This module implements the ClawWork economic model for AI agents,
|
||||
//! tracking balance, costs, income, and survival status. Agents start
|
||||
//! with initial capital and must manage their resources while completing
|
||||
//! tasks.
|
||||
//!
|
||||
//! ## Overview
|
||||
//!
|
||||
//! The economic system models agent viability:
|
||||
//! - **Balance**: Starting capital minus costs plus earned income
|
||||
//! - **Costs**: LLM tokens, search APIs, OCR, and other service usage
|
||||
//! - **Income**: Payments for completed tasks (with quality threshold)
|
||||
//! - **Status**: Health indicator based on remaining capital percentage
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use zeroclaw::economic::{EconomicTracker, EconomicConfig, SurvivalStatus};
|
||||
//!
|
||||
//! let config = EconomicConfig {
|
||||
//! enabled: true,
|
||||
//! initial_balance: 1000.0,
|
||||
//! ..Default::default()
|
||||
//! };
|
||||
//!
|
||||
//! let tracker = EconomicTracker::new("my-agent", config, None);
|
||||
//! tracker.initialize()?;
|
||||
//!
|
||||
//! // Start a task
|
||||
//! tracker.start_task("task-001", None);
|
||||
//!
|
||||
//! // Track LLM usage
|
||||
//! let cost = tracker.track_tokens(1000, 500, "agent", None);
|
||||
//!
|
||||
//! // Complete task and earn income
|
||||
//! tracker.end_task()?;
|
||||
//! let payment = tracker.add_work_income(10.0, "task-001", 0.85, "Completed task")?;
|
||||
//!
|
||||
//! // Check survival status
|
||||
//! match tracker.get_survival_status() {
|
||||
//! SurvivalStatus::Thriving => println!("Agent is healthy!"),
|
||||
//! SurvivalStatus::Bankrupt => println!("Agent needs intervention!"),
|
||||
//! _ => {}
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ## Persistence
|
||||
//!
|
||||
//! Economic state is persisted to JSONL files:
|
||||
//! - `balance.jsonl`: Daily balance snapshots and cumulative totals
|
||||
//! - `token_costs.jsonl`: Detailed per-task cost records
|
||||
//! - `task_completions.jsonl`: Task completion statistics
|
||||
//!
|
||||
//! ## Configuration
|
||||
//!
|
||||
//! Add to `config.toml`:
|
||||
//!
|
||||
//! ```toml
|
||||
//! [economic]
|
||||
//! enabled = true
|
||||
//! initial_balance = 1000.0
|
||||
//! min_evaluation_threshold = 0.6
|
||||
//!
|
||||
//! [economic.token_pricing]
|
||||
//! input_price_per_million = 3.0
|
||||
//! output_price_per_million = 15.0
|
||||
//! ```
|
||||
|
||||
pub mod classifier;
|
||||
pub mod costs;
|
||||
pub mod status;
|
||||
pub mod tracker;
|
||||
|
||||
// Re-exports for convenient access
|
||||
pub use costs::{
|
||||
ApiCallRecord, ApiUsageSummary, BalanceRecord, CostBreakdown, DateCostSummary,
|
||||
EconomicAnalytics, LlmCallRecord, LlmUsageSummary, PricingModel, TaskCompletionRecord,
|
||||
TaskCostRecord, TaskCostSummary, TokenPricing, WorkIncomeRecord,
|
||||
};
|
||||
pub use status::SurvivalStatus;
|
||||
pub use tracker::{EconomicConfig, EconomicSummary, EconomicTracker};
|
||||
pub use classifier::{
|
||||
ClassificationResult, Occupation, OccupationCategory, TaskClassifier,
|
||||
};
|
||||
212
src/economic/status.rs
Normal file
212
src/economic/status.rs
Normal file
@ -0,0 +1,212 @@
|
||||
//! Survival status tracking for economic agents.
|
||||
//!
|
||||
//! Defines the health states an agent can be in based on remaining balance
|
||||
//! as a percentage of initial capital.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
/// Survival status based on balance percentage relative to initial capital.
|
||||
///
|
||||
/// Mirrors the ClawWork LiveBench agent survival states.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SurvivalStatus {
|
||||
/// Balance > 80% of initial - Agent is profitable and healthy
|
||||
Thriving,
|
||||
/// Balance 40-80% of initial - Agent is maintaining stability
|
||||
Stable,
|
||||
/// Balance 10-40% of initial - Agent is losing money, needs attention
|
||||
Struggling,
|
||||
/// Balance 1-10% of initial - Agent is near death, urgent intervention needed
|
||||
Critical,
|
||||
/// Balance <= 0 - Agent has exhausted resources and cannot operate
|
||||
Bankrupt,
|
||||
}
|
||||
|
||||
impl SurvivalStatus {
|
||||
/// Calculate survival status from current and initial balance.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `current_balance` - Current remaining balance
|
||||
/// * `initial_balance` - Starting balance
|
||||
///
|
||||
/// # Returns
|
||||
/// The appropriate `SurvivalStatus` based on the percentage remaining.
|
||||
pub fn from_balance(current_balance: f64, initial_balance: f64) -> Self {
|
||||
if initial_balance <= 0.0 {
|
||||
// Edge case: if initial was zero or negative, can't calculate percentage
|
||||
return if current_balance <= 0.0 {
|
||||
Self::Bankrupt
|
||||
} else {
|
||||
Self::Thriving
|
||||
};
|
||||
}
|
||||
|
||||
let percentage = (current_balance / initial_balance) * 100.0;
|
||||
|
||||
match percentage {
|
||||
p if p <= 0.0 => Self::Bankrupt,
|
||||
p if p < 10.0 => Self::Critical,
|
||||
p if p < 40.0 => Self::Struggling,
|
||||
p if p < 80.0 => Self::Stable,
|
||||
_ => Self::Thriving,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the agent can still operate (not bankrupt).
|
||||
pub fn is_operational(&self) -> bool {
|
||||
!matches!(self, Self::Bankrupt)
|
||||
}
|
||||
|
||||
/// Check if the agent needs urgent attention.
|
||||
pub fn needs_intervention(&self) -> bool {
|
||||
matches!(self, Self::Critical | Self::Bankrupt)
|
||||
}
|
||||
|
||||
/// Get a human-readable emoji indicator.
|
||||
pub fn emoji(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Thriving => "🌟",
|
||||
Self::Stable => "✅",
|
||||
Self::Struggling => "⚠️",
|
||||
Self::Critical => "🚨",
|
||||
Self::Bankrupt => "💀",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a color code for terminal output (ANSI).
|
||||
pub fn ansi_color(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Thriving => "\x1b[32m", // Green
|
||||
Self::Stable => "\x1b[34m", // Blue
|
||||
Self::Struggling => "\x1b[33m", // Yellow
|
||||
Self::Critical => "\x1b[31m", // Red
|
||||
Self::Bankrupt => "\x1b[35m", // Magenta
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SurvivalStatus {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let status = match self {
|
||||
Self::Thriving => "Thriving",
|
||||
Self::Stable => "Stable",
|
||||
Self::Struggling => "Struggling",
|
||||
Self::Critical => "Critical",
|
||||
Self::Bankrupt => "Bankrupt",
|
||||
};
|
||||
write!(f, "{}", status)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SurvivalStatus {
|
||||
fn default() -> Self {
|
||||
Self::Stable
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn thriving_above_80_percent() {
|
||||
assert_eq!(
|
||||
SurvivalStatus::from_balance(900.0, 1000.0),
|
||||
SurvivalStatus::Thriving
|
||||
);
|
||||
assert_eq!(
|
||||
SurvivalStatus::from_balance(1500.0, 1000.0), // Profit!
|
||||
SurvivalStatus::Thriving
|
||||
);
|
||||
assert_eq!(
|
||||
SurvivalStatus::from_balance(800.01, 1000.0),
|
||||
SurvivalStatus::Thriving
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_between_40_and_80_percent() {
|
||||
assert_eq!(
|
||||
SurvivalStatus::from_balance(799.99, 1000.0),
|
||||
SurvivalStatus::Stable
|
||||
);
|
||||
assert_eq!(
|
||||
SurvivalStatus::from_balance(500.0, 1000.0),
|
||||
SurvivalStatus::Stable
|
||||
);
|
||||
assert_eq!(
|
||||
SurvivalStatus::from_balance(400.01, 1000.0),
|
||||
SurvivalStatus::Stable
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn struggling_between_10_and_40_percent() {
|
||||
assert_eq!(
|
||||
SurvivalStatus::from_balance(399.99, 1000.0),
|
||||
SurvivalStatus::Struggling
|
||||
);
|
||||
assert_eq!(
|
||||
SurvivalStatus::from_balance(200.0, 1000.0),
|
||||
SurvivalStatus::Struggling
|
||||
);
|
||||
assert_eq!(
|
||||
SurvivalStatus::from_balance(100.01, 1000.0),
|
||||
SurvivalStatus::Struggling
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn critical_between_0_and_10_percent() {
|
||||
assert_eq!(
|
||||
SurvivalStatus::from_balance(99.99, 1000.0),
|
||||
SurvivalStatus::Critical
|
||||
);
|
||||
assert_eq!(
|
||||
SurvivalStatus::from_balance(50.0, 1000.0),
|
||||
SurvivalStatus::Critical
|
||||
);
|
||||
assert_eq!(
|
||||
SurvivalStatus::from_balance(0.01, 1000.0),
|
||||
SurvivalStatus::Critical
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bankrupt_at_zero_or_negative() {
|
||||
assert_eq!(
|
||||
SurvivalStatus::from_balance(0.0, 1000.0),
|
||||
SurvivalStatus::Bankrupt
|
||||
);
|
||||
assert_eq!(
|
||||
SurvivalStatus::from_balance(-100.0, 1000.0),
|
||||
SurvivalStatus::Bankrupt
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_operational() {
|
||||
assert!(SurvivalStatus::Thriving.is_operational());
|
||||
assert!(SurvivalStatus::Stable.is_operational());
|
||||
assert!(SurvivalStatus::Struggling.is_operational());
|
||||
assert!(SurvivalStatus::Critical.is_operational());
|
||||
assert!(!SurvivalStatus::Bankrupt.is_operational());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_intervention() {
|
||||
assert!(!SurvivalStatus::Thriving.needs_intervention());
|
||||
assert!(!SurvivalStatus::Stable.needs_intervention());
|
||||
assert!(!SurvivalStatus::Struggling.needs_intervention());
|
||||
assert!(SurvivalStatus::Critical.needs_intervention());
|
||||
assert!(SurvivalStatus::Bankrupt.needs_intervention());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_format() {
|
||||
assert_eq!(format!("{}", SurvivalStatus::Thriving), "Thriving");
|
||||
assert_eq!(format!("{}", SurvivalStatus::Bankrupt), "Bankrupt");
|
||||
}
|
||||
}
|
||||
995
src/economic/tracker.rs
Normal file
995
src/economic/tracker.rs
Normal file
@ -0,0 +1,995 @@
|
||||
//! Economic tracker for agent survival economics.
|
||||
//!
|
||||
//! Tracks balance, token costs, work income, and survival status following
|
||||
//! the ClawWork LiveBench economic model. Persists state to JSONL files.
|
||||
|
||||
use super::costs::{
|
||||
ApiCallRecord, BalanceRecord, CostBreakdown, LlmCallRecord, LlmUsageSummary,
|
||||
ApiUsageSummary, PricingModel, TaskCompletionRecord, TaskCostRecord, TokenPricing,
|
||||
WorkIncomeRecord,
|
||||
};
|
||||
use super::status::SurvivalStatus;
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Economic configuration options.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EconomicConfig {
|
||||
/// Enable economic tracking
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Starting balance in USD
|
||||
#[serde(default = "default_initial_balance")]
|
||||
pub initial_balance: f64,
|
||||
/// Token pricing configuration
|
||||
#[serde(default)]
|
||||
pub token_pricing: TokenPricing,
|
||||
/// Minimum evaluation score to receive payment (0.0-1.0)
|
||||
#[serde(default = "default_min_threshold")]
|
||||
pub min_evaluation_threshold: f64,
|
||||
}
|
||||
|
||||
fn default_initial_balance() -> f64 {
|
||||
1000.0
|
||||
}
|
||||
|
||||
fn default_min_threshold() -> f64 {
|
||||
0.6
|
||||
}
|
||||
|
||||
impl Default for EconomicConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
initial_balance: default_initial_balance(),
|
||||
token_pricing: TokenPricing::default(),
|
||||
min_evaluation_threshold: default_min_threshold(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Task-level tracking state (in-memory during task execution).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct TaskState {
|
||||
/// Current task ID
|
||||
task_id: Option<String>,
|
||||
/// Date the task was assigned
|
||||
task_date: Option<String>,
|
||||
/// Task start timestamp
|
||||
start_time: Option<DateTime<Utc>>,
|
||||
/// Costs accumulated for this task
|
||||
costs: CostBreakdown,
|
||||
/// LLM call records
|
||||
llm_calls: Vec<LlmCallRecord>,
|
||||
/// API call records
|
||||
api_calls: Vec<ApiCallRecord>,
|
||||
}
|
||||
|
||||
impl TaskState {
|
||||
fn reset(&mut self) {
|
||||
self.task_id = None;
|
||||
self.task_date = None;
|
||||
self.start_time = None;
|
||||
self.costs.reset();
|
||||
self.llm_calls.clear();
|
||||
self.api_calls.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Daily tracking state (accumulated across tasks).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct DailyState {
|
||||
/// Task IDs completed today
|
||||
task_ids: Vec<String>,
|
||||
/// First task start time
|
||||
first_task_start: Option<DateTime<Utc>>,
|
||||
/// Last task end time
|
||||
last_task_end: Option<DateTime<Utc>>,
|
||||
/// Daily cost accumulator
|
||||
cost: f64,
|
||||
}
|
||||
|
||||
impl DailyState {
|
||||
fn reset(&mut self) {
|
||||
self.task_ids.clear();
|
||||
self.first_task_start = None;
|
||||
self.last_task_end = None;
|
||||
self.cost = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Session tracking state.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct SessionState {
|
||||
/// Input tokens this session
|
||||
input_tokens: u64,
|
||||
/// Output tokens this session
|
||||
output_tokens: u64,
|
||||
/// Cost this session
|
||||
cost: f64,
|
||||
}
|
||||
|
||||
impl SessionState {
|
||||
fn reset(&mut self) {
|
||||
self.input_tokens = 0;
|
||||
self.output_tokens = 0;
|
||||
self.cost = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Economic tracker for managing agent survival economics.
|
||||
///
|
||||
/// Tracks:
|
||||
/// - Balance (starting capital minus costs plus income)
|
||||
/// - Token costs separated by channel (LLM, search, OCR, etc.)
|
||||
/// - Work income with evaluation threshold
|
||||
/// - Trading profits/losses
|
||||
/// - Survival status
|
||||
///
|
||||
/// Persists records to JSONL files for durability and analysis.
|
||||
pub struct EconomicTracker {
|
||||
/// Configuration
|
||||
config: EconomicConfig,
|
||||
/// Agent signature/name
|
||||
signature: String,
|
||||
/// Data directory for persistence
|
||||
data_path: PathBuf,
|
||||
/// Current balance (protected by mutex for thread safety)
|
||||
state: Arc<Mutex<TrackerState>>,
|
||||
}
|
||||
|
||||
/// Internal mutable state.
|
||||
struct TrackerState {
|
||||
/// Current balance
|
||||
balance: f64,
|
||||
/// Initial balance (for status calculation)
|
||||
initial_balance: f64,
|
||||
/// Cumulative totals
|
||||
total_token_cost: f64,
|
||||
total_work_income: f64,
|
||||
total_trading_profit: f64,
|
||||
/// Task-level tracking
|
||||
task: TaskState,
|
||||
/// Daily tracking
|
||||
daily: DailyState,
|
||||
/// Session tracking
|
||||
session: SessionState,
|
||||
}
|
||||
|
||||
impl EconomicTracker {
|
||||
/// Create a new economic tracker.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `signature` - Agent signature/name for identification
|
||||
/// * `config` - Economic configuration
|
||||
/// * `data_path` - Optional custom data path (defaults to `./data/agent_data/{signature}/economic`)
|
||||
pub fn new(
|
||||
signature: impl Into<String>,
|
||||
config: EconomicConfig,
|
||||
data_path: Option<PathBuf>,
|
||||
) -> Self {
|
||||
let signature = signature.into();
|
||||
let data_path = data_path.unwrap_or_else(|| {
|
||||
PathBuf::from(format!("./data/agent_data/{}/economic", signature))
|
||||
});
|
||||
|
||||
Self {
|
||||
signature,
|
||||
state: Arc::new(Mutex::new(TrackerState {
|
||||
balance: config.initial_balance,
|
||||
initial_balance: config.initial_balance,
|
||||
total_token_cost: 0.0,
|
||||
total_work_income: 0.0,
|
||||
total_trading_profit: 0.0,
|
||||
task: TaskState::default(),
|
||||
daily: DailyState::default(),
|
||||
session: SessionState::default(),
|
||||
})),
|
||||
config,
|
||||
data_path,
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the tracker, loading existing state or creating new.
|
||||
pub fn initialize(&self) -> Result<()> {
|
||||
fs::create_dir_all(&self.data_path).with_context(|| {
|
||||
format!("Failed to create data directory: {}", self.data_path.display())
|
||||
})?;
|
||||
|
||||
let balance_file = self.balance_file_path();
|
||||
|
||||
if balance_file.exists() {
|
||||
self.load_latest_state()?;
|
||||
let state = self.state.lock();
|
||||
tracing::info!(
|
||||
"📊 Loaded economic state for {}: balance=${:.2}, status={}",
|
||||
self.signature,
|
||||
state.balance,
|
||||
self.get_survival_status_inner(&state)
|
||||
);
|
||||
} else {
|
||||
self.save_balance_record(
|
||||
"initialization",
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
Vec::new(),
|
||||
false,
|
||||
)?;
|
||||
tracing::info!(
|
||||
"✅ Initialized economic tracker for {}: starting balance=${:.2}",
|
||||
self.signature,
|
||||
self.config.initial_balance
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start tracking costs for a new task.
|
||||
pub fn start_task(&self, task_id: impl Into<String>, date: Option<String>) {
|
||||
let task_id = task_id.into();
|
||||
let date = date.unwrap_or_else(|| Utc::now().format("%Y-%m-%d").to_string());
|
||||
let now = Utc::now();
|
||||
|
||||
let mut state = self.state.lock();
|
||||
state.task.task_id = Some(task_id.clone());
|
||||
state.task.task_date = Some(date);
|
||||
state.task.start_time = Some(now);
|
||||
state.task.costs.reset();
|
||||
state.task.llm_calls.clear();
|
||||
state.task.api_calls.clear();
|
||||
|
||||
// Track daily window
|
||||
if state.daily.first_task_start.is_none() {
|
||||
state.daily.first_task_start = Some(now);
|
||||
}
|
||||
state.daily.task_ids.push(task_id);
|
||||
}
|
||||
|
||||
/// End tracking for current task and save consolidated record.
|
||||
pub fn end_task(&self) -> Result<()> {
|
||||
let mut state = self.state.lock();
|
||||
|
||||
if state.task.task_id.is_some() {
|
||||
self.save_task_record_inner(&state)?;
|
||||
state.daily.last_task_end = Some(Utc::now());
|
||||
state.task.reset();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Track LLM token usage.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `input_tokens` - Number of input tokens
|
||||
/// * `output_tokens` - Number of output tokens
|
||||
/// * `api_name` - Origin of the call (e.g., "agent", "wrapup")
|
||||
/// * `cost` - Pre-computed cost (if provided, skips local calculation)
|
||||
///
|
||||
/// # Returns
|
||||
/// The cost in USD for this call.
|
||||
pub fn track_tokens(
|
||||
&self,
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
api_name: impl Into<String>,
|
||||
cost: Option<f64>,
|
||||
) -> f64 {
|
||||
let api_name = api_name.into();
|
||||
let cost = cost.unwrap_or_else(|| {
|
||||
self.config.token_pricing.calculate_cost(input_tokens, output_tokens)
|
||||
});
|
||||
|
||||
let mut state = self.state.lock();
|
||||
|
||||
// Update session tracking
|
||||
state.session.input_tokens += input_tokens;
|
||||
state.session.output_tokens += output_tokens;
|
||||
state.session.cost += cost;
|
||||
state.daily.cost += cost;
|
||||
|
||||
// Update task-level tracking
|
||||
state.task.costs.llm_tokens += cost;
|
||||
state.task.llm_calls.push(LlmCallRecord {
|
||||
timestamp: Utc::now(),
|
||||
api_name,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cost,
|
||||
});
|
||||
|
||||
// Update totals
|
||||
state.total_token_cost += cost;
|
||||
state.balance -= cost;
|
||||
|
||||
cost
|
||||
}
|
||||
|
||||
/// Track token-based API call cost.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `tokens` - Number of tokens used
|
||||
/// * `price_per_million` - Price per million tokens
|
||||
/// * `api_name` - Name of the API
|
||||
///
|
||||
/// # Returns
|
||||
/// The cost in USD for this call.
|
||||
pub fn track_api_call(
|
||||
&self,
|
||||
tokens: u64,
|
||||
price_per_million: f64,
|
||||
api_name: impl Into<String>,
|
||||
) -> f64 {
|
||||
let api_name = api_name.into();
|
||||
let cost = (tokens as f64 / 1_000_000.0) * price_per_million;
|
||||
|
||||
self.record_api_cost(&api_name, cost, Some(tokens), Some(price_per_million), PricingModel::PerToken);
|
||||
|
||||
cost
|
||||
}
|
||||
|
||||
/// Track flat-rate API call cost.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `cost` - Flat cost in USD
|
||||
/// * `api_name` - Name of the API
|
||||
///
|
||||
/// # Returns
|
||||
/// The cost (same as input).
|
||||
pub fn track_flat_api_call(&self, cost: f64, api_name: impl Into<String>) -> f64 {
|
||||
let api_name = api_name.into();
|
||||
self.record_api_cost(&api_name, cost, None, None, PricingModel::FlatRate);
|
||||
cost
|
||||
}
|
||||
|
||||
fn record_api_cost(
|
||||
&self,
|
||||
api_name: &str,
|
||||
cost: f64,
|
||||
tokens: Option<u64>,
|
||||
price_per_million: Option<f64>,
|
||||
pricing_model: PricingModel,
|
||||
) {
|
||||
let mut state = self.state.lock();
|
||||
|
||||
// Update session/daily
|
||||
state.session.cost += cost;
|
||||
state.daily.cost += cost;
|
||||
|
||||
// Categorize by API type
|
||||
let api_lower = api_name.to_lowercase();
|
||||
if api_lower.contains("search") || api_lower.contains("jina") || api_lower.contains("tavily") {
|
||||
state.task.costs.search_api += cost;
|
||||
} else if api_lower.contains("ocr") {
|
||||
state.task.costs.ocr_api += cost;
|
||||
} else {
|
||||
state.task.costs.other_api += cost;
|
||||
}
|
||||
|
||||
// Record detailed call
|
||||
state.task.api_calls.push(ApiCallRecord {
|
||||
timestamp: Utc::now(),
|
||||
api_name: api_name.to_string(),
|
||||
pricing_model,
|
||||
tokens,
|
||||
price_per_million,
|
||||
cost,
|
||||
});
|
||||
|
||||
// Update totals
|
||||
state.total_token_cost += cost;
|
||||
state.balance -= cost;
|
||||
}
|
||||
|
||||
/// Add income from completed work with evaluation threshold.
|
||||
///
|
||||
/// Payment is only awarded if `evaluation_score >= min_evaluation_threshold`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `amount` - Base payment amount in USD
|
||||
/// * `task_id` - Task identifier
|
||||
/// * `evaluation_score` - Score from 0.0 to 1.0
|
||||
/// * `description` - Optional description
|
||||
///
|
||||
/// # Returns
|
||||
/// Actual payment received (0.0 if below threshold).
|
||||
pub fn add_work_income(
|
||||
&self,
|
||||
amount: f64,
|
||||
task_id: impl Into<String>,
|
||||
evaluation_score: f64,
|
||||
description: impl Into<String>,
|
||||
) -> Result<f64> {
|
||||
let task_id = task_id.into();
|
||||
let description = description.into();
|
||||
let threshold = self.config.min_evaluation_threshold;
|
||||
|
||||
let actual_payment = if evaluation_score >= threshold {
|
||||
amount
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
{
|
||||
let mut state = self.state.lock();
|
||||
if actual_payment > 0.0 {
|
||||
state.balance += actual_payment;
|
||||
state.total_work_income += actual_payment;
|
||||
tracing::info!(
|
||||
"💰 Work income: +${:.2} (Task: {}, Score: {:.2})",
|
||||
actual_payment,
|
||||
task_id,
|
||||
evaluation_score
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"⚠️ Work below threshold (score: {:.2} < {:.2}), no payment for task: {}",
|
||||
evaluation_score,
|
||||
threshold,
|
||||
task_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
self.log_work_income(
|
||||
&task_id,
|
||||
amount,
|
||||
actual_payment,
|
||||
evaluation_score,
|
||||
&description,
|
||||
)?;
|
||||
|
||||
Ok(actual_payment)
|
||||
}
|
||||
|
||||
/// Add profit/loss from trading.
|
||||
pub fn add_trading_profit(&self, profit: f64, _description: impl Into<String>) {
|
||||
let mut state = self.state.lock();
|
||||
state.balance += profit;
|
||||
state.total_trading_profit += profit;
|
||||
|
||||
let sign = if profit >= 0.0 { "+" } else { "" };
|
||||
tracing::info!(
|
||||
"📈 Trading P&L: {}${:.2}, new balance: ${:.2}",
|
||||
sign,
|
||||
profit,
|
||||
state.balance
|
||||
);
|
||||
}
|
||||
|
||||
/// Save end-of-day economic state.
|
||||
pub fn save_daily_state(
|
||||
&self,
|
||||
date: &str,
|
||||
work_income: f64,
|
||||
trading_profit: f64,
|
||||
completed_tasks: Vec<String>,
|
||||
api_error: bool,
|
||||
) -> Result<()> {
|
||||
let daily_cost = {
|
||||
let state = self.state.lock();
|
||||
state.daily.cost
|
||||
};
|
||||
|
||||
self.save_balance_record(
|
||||
date,
|
||||
daily_cost,
|
||||
work_income,
|
||||
trading_profit,
|
||||
completed_tasks,
|
||||
api_error,
|
||||
)?;
|
||||
|
||||
// Reset daily tracking
|
||||
{
|
||||
let mut state = self.state.lock();
|
||||
state.daily.reset();
|
||||
state.session.reset();
|
||||
}
|
||||
|
||||
tracing::info!("💾 Saved daily state for {}", date);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current balance.
|
||||
pub fn get_balance(&self) -> f64 {
|
||||
self.state.lock().balance
|
||||
}
|
||||
|
||||
/// Get net worth (balance + portfolio value).
|
||||
pub fn get_net_worth(&self) -> f64 {
|
||||
// TODO: Add trading portfolio value
|
||||
self.get_balance()
|
||||
}
|
||||
|
||||
/// Get current survival status.
|
||||
pub fn get_survival_status(&self) -> SurvivalStatus {
|
||||
let state = self.state.lock();
|
||||
self.get_survival_status_inner(&state)
|
||||
}
|
||||
|
||||
fn get_survival_status_inner(&self, state: &TrackerState) -> SurvivalStatus {
|
||||
SurvivalStatus::from_balance(state.balance, state.initial_balance)
|
||||
}
|
||||
|
||||
/// Check if agent is bankrupt.
|
||||
pub fn is_bankrupt(&self) -> bool {
|
||||
self.get_survival_status() == SurvivalStatus::Bankrupt
|
||||
}
|
||||
|
||||
/// Get session cost so far.
|
||||
pub fn get_session_cost(&self) -> f64 {
|
||||
self.state.lock().session.cost
|
||||
}
|
||||
|
||||
/// Get daily cost so far.
|
||||
pub fn get_daily_cost(&self) -> f64 {
|
||||
self.state.lock().daily.cost
|
||||
}
|
||||
|
||||
/// Get comprehensive economic summary.
|
||||
pub fn get_summary(&self) -> EconomicSummary {
|
||||
let state = self.state.lock();
|
||||
EconomicSummary {
|
||||
signature: self.signature.clone(),
|
||||
balance: state.balance,
|
||||
initial_balance: state.initial_balance,
|
||||
net_worth: state.balance, // TODO: Add portfolio
|
||||
total_token_cost: state.total_token_cost,
|
||||
total_work_income: state.total_work_income,
|
||||
total_trading_profit: state.total_trading_profit,
|
||||
session_cost: state.session.cost,
|
||||
daily_cost: state.daily.cost,
|
||||
session_input_tokens: state.session.input_tokens,
|
||||
session_output_tokens: state.session.output_tokens,
|
||||
survival_status: self.get_survival_status_inner(&state),
|
||||
is_bankrupt: self.get_survival_status_inner(&state) == SurvivalStatus::Bankrupt,
|
||||
min_evaluation_threshold: self.config.min_evaluation_threshold,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset session tracking (for new decision/activity).
|
||||
pub fn reset_session(&self) {
|
||||
self.state.lock().session.reset();
|
||||
}
|
||||
|
||||
/// Record task completion statistics.
|
||||
pub fn record_task_completion(
|
||||
&self,
|
||||
task_id: impl Into<String>,
|
||||
work_submitted: bool,
|
||||
wall_clock_seconds: f64,
|
||||
evaluation_score: f64,
|
||||
money_earned: f64,
|
||||
attempt: u32,
|
||||
date: Option<String>,
|
||||
) -> Result<()> {
|
||||
let task_id = task_id.into();
|
||||
let date = date.or_else(|| {
|
||||
self.state.lock().task.task_date.clone()
|
||||
}).unwrap_or_else(|| Utc::now().format("%Y-%m-%d").to_string());
|
||||
|
||||
let record = TaskCompletionRecord {
|
||||
task_id: task_id.clone(),
|
||||
date,
|
||||
attempt,
|
||||
work_submitted,
|
||||
evaluation_score,
|
||||
money_earned,
|
||||
wall_clock_seconds,
|
||||
timestamp: Utc::now(),
|
||||
};
|
||||
|
||||
// Read existing records, filter out this task_id
|
||||
let completions_file = self.task_completions_file_path();
|
||||
let mut existing: Vec<String> = Vec::new();
|
||||
|
||||
if completions_file.exists() {
|
||||
let file = File::open(&completions_file)?;
|
||||
let reader = BufReader::new(file);
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entry) = serde_json::from_str::<TaskCompletionRecord>(&line) {
|
||||
if entry.task_id != task_id {
|
||||
existing.push(line);
|
||||
}
|
||||
} else {
|
||||
existing.push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite with updated record
|
||||
let mut file = File::create(&completions_file)?;
|
||||
for line in existing {
|
||||
writeln!(file, "{}", line)?;
|
||||
}
|
||||
writeln!(file, "{}", serde_json::to_string(&record)?)?;
|
||||
file.sync_all()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Private helpers ──
|
||||
|
||||
fn balance_file_path(&self) -> PathBuf {
|
||||
self.data_path.join("balance.jsonl")
|
||||
}
|
||||
|
||||
fn token_costs_file_path(&self) -> PathBuf {
|
||||
self.data_path.join("token_costs.jsonl")
|
||||
}
|
||||
|
||||
fn task_completions_file_path(&self) -> PathBuf {
|
||||
self.data_path.join("task_completions.jsonl")
|
||||
}
|
||||
|
||||
fn load_latest_state(&self) -> Result<()> {
|
||||
let balance_file = self.balance_file_path();
|
||||
let file = File::open(&balance_file)?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
let mut last_record: Option<BalanceRecord> = None;
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
if let Ok(record) = serde_json::from_str::<BalanceRecord>(&line) {
|
||||
last_record = Some(record);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(record) = last_record {
|
||||
let mut state = self.state.lock();
|
||||
state.balance = record.balance;
|
||||
state.total_token_cost = record.total_token_cost;
|
||||
state.total_work_income = record.total_work_income;
|
||||
state.total_trading_profit = record.total_trading_profit;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_task_record_inner(&self, state: &TrackerState) -> Result<()> {
|
||||
let Some(ref task_id) = state.task.task_id else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let total_input = state.task.llm_calls.iter().map(|c| c.input_tokens).sum();
|
||||
let total_output = state.task.llm_calls.iter().map(|c| c.output_tokens).sum();
|
||||
let llm_call_count = state.task.llm_calls.len();
|
||||
|
||||
let token_based = state.task.api_calls.iter()
|
||||
.filter(|c| c.pricing_model == PricingModel::PerToken)
|
||||
.count();
|
||||
let flat_rate = state.task.api_calls.iter()
|
||||
.filter(|c| c.pricing_model == PricingModel::FlatRate)
|
||||
.count();
|
||||
|
||||
let record = TaskCostRecord {
|
||||
timestamp_end: Utc::now(),
|
||||
timestamp_start: state.task.start_time.unwrap_or_else(Utc::now),
|
||||
date: state.task.task_date.clone().unwrap_or_else(|| Utc::now().format("%Y-%m-%d").to_string()),
|
||||
task_id: task_id.clone(),
|
||||
llm_usage: LlmUsageSummary {
|
||||
total_calls: llm_call_count,
|
||||
total_input_tokens: total_input,
|
||||
total_output_tokens: total_output,
|
||||
total_tokens: total_input + total_output,
|
||||
total_cost: state.task.costs.llm_tokens,
|
||||
input_price_per_million: self.config.token_pricing.input_price_per_million,
|
||||
output_price_per_million: self.config.token_pricing.output_price_per_million,
|
||||
calls_detail: state.task.llm_calls.clone(),
|
||||
},
|
||||
api_usage: ApiUsageSummary {
|
||||
total_calls: state.task.api_calls.len(),
|
||||
search_api_cost: state.task.costs.search_api,
|
||||
ocr_api_cost: state.task.costs.ocr_api,
|
||||
other_api_cost: state.task.costs.other_api,
|
||||
token_based_calls: token_based,
|
||||
flat_rate_calls: flat_rate,
|
||||
calls_detail: state.task.api_calls.clone(),
|
||||
},
|
||||
cost_summary: state.task.costs.clone(),
|
||||
balance_after: state.balance,
|
||||
session_cost: state.session.cost,
|
||||
daily_cost: state.daily.cost,
|
||||
};
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(self.token_costs_file_path())?;
|
||||
writeln!(file, "{}", serde_json::to_string(&record)?)?;
|
||||
file.sync_all()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_balance_record(
|
||||
&self,
|
||||
date: &str,
|
||||
token_cost_delta: f64,
|
||||
work_income_delta: f64,
|
||||
trading_profit_delta: f64,
|
||||
completed_tasks: Vec<String>,
|
||||
api_error: bool,
|
||||
) -> Result<()> {
|
||||
let state = self.state.lock();
|
||||
|
||||
let task_completion_time = match (state.daily.first_task_start, state.daily.last_task_end) {
|
||||
(Some(start), Some(end)) => Some((end - start).num_seconds() as f64),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let record = BalanceRecord {
|
||||
date: date.to_string(),
|
||||
balance: state.balance,
|
||||
token_cost_delta,
|
||||
work_income_delta,
|
||||
trading_profit_delta,
|
||||
total_token_cost: state.total_token_cost,
|
||||
total_work_income: state.total_work_income,
|
||||
total_trading_profit: state.total_trading_profit,
|
||||
net_worth: state.balance,
|
||||
survival_status: self.get_survival_status_inner(&state).to_string(),
|
||||
completed_tasks,
|
||||
task_id: state.daily.task_ids.first().cloned(),
|
||||
task_completion_time_seconds: task_completion_time,
|
||||
api_error,
|
||||
};
|
||||
|
||||
drop(state); // Release lock before IO
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(self.balance_file_path())?;
|
||||
writeln!(file, "{}", serde_json::to_string(&record)?)?;
|
||||
file.sync_all()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn log_work_income(
|
||||
&self,
|
||||
task_id: &str,
|
||||
base_amount: f64,
|
||||
actual_payment: f64,
|
||||
evaluation_score: f64,
|
||||
description: &str,
|
||||
) -> Result<()> {
|
||||
let state = self.state.lock();
|
||||
|
||||
let record = WorkIncomeRecord {
|
||||
timestamp: Utc::now(),
|
||||
date: state.task.task_date.clone()
|
||||
.unwrap_or_else(|| Utc::now().format("%Y-%m-%d").to_string()),
|
||||
task_id: task_id.to_string(),
|
||||
base_amount,
|
||||
actual_payment,
|
||||
evaluation_score,
|
||||
threshold: self.config.min_evaluation_threshold,
|
||||
payment_awarded: actual_payment > 0.0,
|
||||
description: description.to_string(),
|
||||
balance_after: state.balance,
|
||||
};
|
||||
|
||||
drop(state);
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(self.token_costs_file_path())?;
|
||||
writeln!(file, "{}", serde_json::to_string(&record)?)?;
|
||||
file.sync_all()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for EconomicTracker {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let state = self.state.lock();
|
||||
write!(
|
||||
f,
|
||||
"EconomicTracker(signature='{}', balance=${:.2}, status={})",
|
||||
self.signature,
|
||||
state.balance,
|
||||
self.get_survival_status_inner(&state)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Comprehensive economic summary.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EconomicSummary {
|
||||
pub signature: String,
|
||||
pub balance: f64,
|
||||
pub initial_balance: f64,
|
||||
pub net_worth: f64,
|
||||
pub total_token_cost: f64,
|
||||
pub total_work_income: f64,
|
||||
pub total_trading_profit: f64,
|
||||
pub session_cost: f64,
|
||||
pub daily_cost: f64,
|
||||
pub session_input_tokens: u64,
|
||||
pub session_output_tokens: u64,
|
||||
pub survival_status: SurvivalStatus,
|
||||
pub is_bankrupt: bool,
|
||||
pub min_evaluation_threshold: f64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn test_config() -> EconomicConfig {
|
||||
EconomicConfig {
|
||||
enabled: true,
|
||||
initial_balance: 1000.0,
|
||||
token_pricing: TokenPricing {
|
||||
input_price_per_million: 3.0,
|
||||
output_price_per_million: 15.0,
|
||||
},
|
||||
min_evaluation_threshold: 0.6,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracker_initialization() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config();
|
||||
let tracker = EconomicTracker::new(
|
||||
"test-agent",
|
||||
config,
|
||||
Some(tmp.path().to_path_buf()),
|
||||
);
|
||||
|
||||
tracker.initialize().unwrap();
|
||||
|
||||
assert!((tracker.get_balance() - 1000.0).abs() < f64::EPSILON);
|
||||
assert_eq!(tracker.get_survival_status(), SurvivalStatus::Thriving);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_tokens_reduces_balance() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tracker = EconomicTracker::new(
|
||||
"test-agent",
|
||||
test_config(),
|
||||
Some(tmp.path().to_path_buf()),
|
||||
);
|
||||
tracker.initialize().unwrap();
|
||||
|
||||
tracker.start_task("task-1", None);
|
||||
let cost = tracker.track_tokens(1000, 500, "agent", None);
|
||||
tracker.end_task().unwrap();
|
||||
|
||||
// (1000/1M)*3 + (500/1M)*15 = 0.003 + 0.0075 = 0.0105
|
||||
assert!((cost - 0.0105).abs() < 0.0001);
|
||||
assert!((tracker.get_balance() - (1000.0 - 0.0105)).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn work_income_with_threshold() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tracker = EconomicTracker::new(
|
||||
"test-agent",
|
||||
test_config(),
|
||||
Some(tmp.path().to_path_buf()),
|
||||
);
|
||||
tracker.initialize().unwrap();
|
||||
|
||||
// Below threshold - no payment
|
||||
let payment = tracker.add_work_income(100.0, "task-1", 0.5, "").unwrap();
|
||||
assert!((payment - 0.0).abs() < f64::EPSILON);
|
||||
assert!((tracker.get_balance() - 1000.0).abs() < f64::EPSILON);
|
||||
|
||||
// At threshold - payment awarded
|
||||
let payment = tracker.add_work_income(100.0, "task-2", 0.6, "").unwrap();
|
||||
assert!((payment - 100.0).abs() < f64::EPSILON);
|
||||
assert!((tracker.get_balance() - 1100.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn survival_status_changes() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut config = test_config();
|
||||
config.initial_balance = 100.0;
|
||||
|
||||
let tracker = EconomicTracker::new(
|
||||
"test-agent",
|
||||
config,
|
||||
Some(tmp.path().to_path_buf()),
|
||||
);
|
||||
tracker.initialize().unwrap();
|
||||
|
||||
assert_eq!(tracker.get_survival_status(), SurvivalStatus::Thriving);
|
||||
|
||||
// Spend 30% - should be stable
|
||||
tracker.track_tokens(10_000_000, 0, "agent", Some(30.0));
|
||||
assert_eq!(tracker.get_survival_status(), SurvivalStatus::Stable);
|
||||
|
||||
// Spend more to reach struggling
|
||||
tracker.track_tokens(10_000_000, 0, "agent", Some(35.0));
|
||||
assert_eq!(tracker.get_survival_status(), SurvivalStatus::Struggling);
|
||||
|
||||
// Spend more to reach critical
|
||||
tracker.track_tokens(10_000_000, 0, "agent", Some(25.0));
|
||||
assert_eq!(tracker.get_survival_status(), SurvivalStatus::Critical);
|
||||
|
||||
// Bankrupt
|
||||
tracker.track_tokens(10_000_000, 0, "agent", Some(20.0));
|
||||
assert_eq!(tracker.get_survival_status(), SurvivalStatus::Bankrupt);
|
||||
assert!(tracker.is_bankrupt());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_persistence() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config();
|
||||
|
||||
// Create tracker, do some work, save state
|
||||
{
|
||||
let tracker = EconomicTracker::new(
|
||||
"test-agent",
|
||||
config.clone(),
|
||||
Some(tmp.path().to_path_buf()),
|
||||
);
|
||||
tracker.initialize().unwrap();
|
||||
tracker.track_tokens(1000, 500, "agent", Some(10.0));
|
||||
tracker.save_daily_state("2025-01-01", 0.0, 0.0, vec![], false).unwrap();
|
||||
}
|
||||
|
||||
// Create new tracker, should load state
|
||||
{
|
||||
let tracker = EconomicTracker::new(
|
||||
"test-agent",
|
||||
config,
|
||||
Some(tmp.path().to_path_buf()),
|
||||
);
|
||||
tracker.initialize().unwrap();
|
||||
assert!((tracker.get_balance() - 990.0).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_call_categorization() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tracker = EconomicTracker::new(
|
||||
"test-agent",
|
||||
test_config(),
|
||||
Some(tmp.path().to_path_buf()),
|
||||
);
|
||||
tracker.initialize().unwrap();
|
||||
|
||||
tracker.start_task("task-1", None);
|
||||
|
||||
// Search API
|
||||
tracker.track_flat_api_call(0.001, "tavily_search");
|
||||
|
||||
// OCR API
|
||||
tracker.track_api_call(1000, 1.0, "ocr_reader");
|
||||
|
||||
// Other API
|
||||
tracker.track_flat_api_call(0.01, "some_api");
|
||||
|
||||
tracker.end_task().unwrap();
|
||||
|
||||
// Balance should reflect all costs
|
||||
let expected_reduction = 0.001 + 0.001 + 0.01; // search + ocr + other
|
||||
assert!((tracker.get_balance() - (1000.0 - expected_reduction)).abs() < 0.0001);
|
||||
}
|
||||
}
|
||||
@ -47,6 +47,7 @@ pub mod config;
|
||||
pub mod coordination;
|
||||
pub(crate) mod cost;
|
||||
pub(crate) mod cron;
|
||||
pub mod economic;
|
||||
pub(crate) mod daemon;
|
||||
pub(crate) mod doctor;
|
||||
pub mod gateway;
|
||||
|
||||
@ -186,6 +186,7 @@ pub async fn run_wizard(force: bool) -> Result<Config> {
|
||||
proxy: crate::config::ProxyConfig::default(),
|
||||
identity: identity_config,
|
||||
cost: crate::config::CostConfig::default(),
|
||||
economic: crate::config::EconomicConfig::default(),
|
||||
peripherals: crate::config::PeripheralsConfig::default(),
|
||||
agents: std::collections::HashMap::new(),
|
||||
hooks: crate::config::HooksConfig::default(),
|
||||
@ -550,6 +551,7 @@ async fn run_quick_setup_with_home(
|
||||
proxy: crate::config::ProxyConfig::default(),
|
||||
identity: crate::config::IdentityConfig::default(),
|
||||
cost: crate::config::CostConfig::default(),
|
||||
economic: crate::config::EconomicConfig::default(),
|
||||
peripherals: crate::config::PeripheralsConfig::default(),
|
||||
agents: std::collections::HashMap::new(),
|
||||
hooks: crate::config::HooksConfig::default(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user