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:
Argenis 2026-02-28 01:29:57 -05:00 committed by GitHub
commit 43e3e9b897
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 2603 additions and 206 deletions

335
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -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,

View File

@ -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
View 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
View 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
View 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
View 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
View 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);
}
}

View File

@ -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;

View File

@ -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(),