microclaw-engine 0.10.1

Channel-independent Agent Engine and default services for MicroClaw
Documentation
use std::sync::Arc;

use async_trait::async_trait;
use serde_json::json;

use super::{authorize_chat_access, schema_object, Tool, ToolResult};
use crate::internal::storage::db::{call_blocking, Database};
use microclaw_core::llm_types::ToolDefinition;

pub struct LearningTracksTool {
    db: Arc<Database>,
    default_timezone: String,
}

impl LearningTracksTool {
    pub fn new(db: Arc<Database>, default_timezone: String) -> Self {
        Self {
            db,
            default_timezone,
        }
    }
}

#[async_trait]
impl Tool for LearningTracksTool {
    fn name(&self) -> &str {
        "learning_tracks"
    }

    fn definition(&self) -> ToolDefinition {
        ToolDefinition {
            name: self.name().into(),
            description: "Create and manage governed background learning tracks. A track researches a user-selected direction on its cron schedule, using explicit source and cost boundaries. It produces inert, source-backed skill candidates for operator review; it never silently activates them.".into(),
            input_schema: schema_object(
                json!({
                    "action": {
                        "type": "string",
                        "enum": ["create", "list", "pause", "resume", "archive", "run_now"]
                    },
                    "chat_id": {"type": "integer"},
                    "track_id": {"type": "string"},
                    "name": {"type": "string", "maxLength": 96},
                    "objective": {"type": "string", "maxLength": 8000},
                    "directions": {
                        "type": "array",
                        "items": {"type": "string"},
                        "description": "Concrete capabilities or questions the track should prioritize"
                    },
                    "allowed_sources": {
                        "type": "array",
                        "items": {"type": "string"},
                        "description": "Allowed domains, repositories, or source classes such as official_docs and peer_reviewed_papers"
                    },
                    "schedule": {
                        "type": "string",
                        "description": "6-field cron expression; defaults to daily at 03:00"
                    },
                    "timezone": {"type": "string"},
                    "token_budget": {"type": "integer", "minimum": 1000, "maximum": 2000000},
                    "max_sources": {"type": "integer", "minimum": 1, "maximum": 100},
                    "promotion_mode": {
                        "type": "string",
                        "enum": ["propose", "manual"],
                        "description": "Both modes require explicit operator promotion; propose is the recommended default"
                    }
                }),
                &["action", "chat_id"],
            ),
        }
    }

    async fn execute(&self, input: serde_json::Value) -> ToolResult {
        let chat_id = match input.get("chat_id").and_then(|value| value.as_i64()) {
            Some(chat_id) => chat_id,
            None => return ToolResult::error("missing chat_id".into()),
        };
        if let Err(error) = authorize_chat_access(&input, chat_id) {
            return ToolResult::error(error);
        }
        let action = input
            .get("action")
            .and_then(|value| value.as_str())
            .unwrap_or("")
            .to_string();
        match action.as_str() {
            "create" => self.create(input, chat_id).await,
            "list" => match call_blocking(self.db.clone(), move |db| {
                db.list_learning_tracks(Some(chat_id))
            })
            .await
            {
                Ok(tracks) => ToolResult::success(
                    serde_json::to_string_pretty(&tracks).unwrap_or_else(|_| "[]".into()),
                ),
                Err(error) => ToolResult::error(error.to_string()),
            },
            "pause" | "resume" | "archive" | "run_now" => {
                self.change_status(input, chat_id, &action).await
            }
            _ => ToolResult::error("invalid learning_tracks action".into()),
        }
    }
}

impl LearningTracksTool {
    async fn create(&self, input: serde_json::Value, chat_id: i64) -> ToolResult {
        let Some(name) = input.get("name").and_then(|value| value.as_str()) else {
            return ToolResult::error("create requires name".into());
        };
        let Some(objective) = input.get("objective").and_then(|value| value.as_str()) else {
            return ToolResult::error("create requires objective".into());
        };
        let directions = input
            .get("directions")
            .cloned()
            .unwrap_or_else(|| json!([]));
        let sources = input
            .get("allowed_sources")
            .cloned()
            .unwrap_or_else(|| json!(["official_docs", "peer_reviewed_papers"]));
        if !directions.is_array() || !sources.is_array() {
            return ToolResult::error("directions and allowed_sources must be arrays".into());
        }
        let schedule = input
            .get("schedule")
            .and_then(|value| value.as_str())
            .unwrap_or("0 0 3 * * *");
        let timezone = input
            .get("timezone")
            .and_then(|value| value.as_str())
            .unwrap_or(&self.default_timezone);
        let next_run = match crate::learning_foundry::compute_next_learning_run(schedule, timezone)
        {
            Ok(next_run) => next_run,
            Err(error) => return ToolResult::error(error),
        };
        let token_budget = input
            .get("token_budget")
            .and_then(|value| value.as_i64())
            .unwrap_or(80_000);
        let max_sources = input
            .get("max_sources")
            .and_then(|value| value.as_i64())
            .unwrap_or(20);
        let promotion_mode = input
            .get("promotion_mode")
            .and_then(|value| value.as_str())
            .unwrap_or("propose");
        let name = name.to_string();
        let objective = objective.to_string();
        let schedule = schedule.to_string();
        let timezone = timezone.to_string();
        let promotion_mode = promotion_mode.to_string();
        let persisted_next_run = next_run.clone();
        match call_blocking(self.db.clone(), move |db| {
            db.create_learning_track(
                chat_id,
                &name,
                &objective,
                &directions,
                &sources,
                &schedule,
                &timezone,
                token_budget,
                max_sources,
                &promotion_mode,
                &persisted_next_run,
            )
        })
        .await
        {
            Ok(track_id) => ToolResult::success(format!(
                "Learning track created: {track_id}. It will run at {next_run} and produce review-only candidates."
            )),
            Err(error) => ToolResult::error(error.to_string()),
        }
    }

    async fn change_status(
        &self,
        input: serde_json::Value,
        chat_id: i64,
        action: &str,
    ) -> ToolResult {
        let Some(track_id) = input.get("track_id").and_then(|value| value.as_str()) else {
            return ToolResult::error(format!("{action} requires track_id"));
        };
        let track_id = track_id.to_string();
        let belongs = call_blocking(self.db.clone(), {
            let track_id = track_id.clone();
            move |db| db.learning_track_belongs_to_chat(&track_id, chat_id)
        })
        .await;
        if !matches!(belongs, Ok(true)) {
            return ToolResult::error("learning track not found in this chat".into());
        }
        let (status, next_run) = match action {
            "pause" => ("paused", None),
            "archive" => ("archived", None),
            "resume" => ("active", None),
            "run_now" => ("active", Some(chrono::Utc::now().to_rfc3339())),
            _ => unreachable!(),
        };
        let status = status.to_string();
        match call_blocking(self.db.clone(), move |db| {
            db.update_learning_track_status(&track_id, &status, next_run.as_deref())
        })
        .await
        {
            Ok(true) => ToolResult::success(format!("Learning track {action} accepted.")),
            Ok(false) => ToolResult::error("learning track not found".into()),
            Err(error) => ToolResult::error(error.to_string()),
        }
    }
}