Skip to main content

microclaw_engine/tools/
learning_tracks.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use serde_json::json;
5
6use super::{authorize_chat_access, schema_object, Tool, ToolResult};
7use crate::internal::storage::db::{call_blocking, Database};
8use microclaw_core::llm_types::ToolDefinition;
9
10pub struct LearningTracksTool {
11    db: Arc<Database>,
12    default_timezone: String,
13}
14
15impl LearningTracksTool {
16    pub fn new(db: Arc<Database>, default_timezone: String) -> Self {
17        Self {
18            db,
19            default_timezone,
20        }
21    }
22}
23
24#[async_trait]
25impl Tool for LearningTracksTool {
26    fn name(&self) -> &str {
27        "learning_tracks"
28    }
29
30    fn definition(&self) -> ToolDefinition {
31        ToolDefinition {
32            name: self.name().into(),
33            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(),
34            input_schema: schema_object(
35                json!({
36                    "action": {
37                        "type": "string",
38                        "enum": ["create", "list", "pause", "resume", "archive", "run_now"]
39                    },
40                    "chat_id": {"type": "integer"},
41                    "track_id": {"type": "string"},
42                    "name": {"type": "string", "maxLength": 96},
43                    "objective": {"type": "string", "maxLength": 8000},
44                    "directions": {
45                        "type": "array",
46                        "items": {"type": "string"},
47                        "description": "Concrete capabilities or questions the track should prioritize"
48                    },
49                    "allowed_sources": {
50                        "type": "array",
51                        "items": {"type": "string"},
52                        "description": "Allowed domains, repositories, or source classes such as official_docs and peer_reviewed_papers"
53                    },
54                    "schedule": {
55                        "type": "string",
56                        "description": "6-field cron expression; defaults to daily at 03:00"
57                    },
58                    "timezone": {"type": "string"},
59                    "token_budget": {"type": "integer", "minimum": 1000, "maximum": 2000000},
60                    "max_sources": {"type": "integer", "minimum": 1, "maximum": 100},
61                    "promotion_mode": {
62                        "type": "string",
63                        "enum": ["propose", "manual"],
64                        "description": "Both modes require explicit operator promotion; propose is the recommended default"
65                    }
66                }),
67                &["action", "chat_id"],
68            ),
69        }
70    }
71
72    async fn execute(&self, input: serde_json::Value) -> ToolResult {
73        let chat_id = match input.get("chat_id").and_then(|value| value.as_i64()) {
74            Some(chat_id) => chat_id,
75            None => return ToolResult::error("missing chat_id".into()),
76        };
77        if let Err(error) = authorize_chat_access(&input, chat_id) {
78            return ToolResult::error(error);
79        }
80        let action = input
81            .get("action")
82            .and_then(|value| value.as_str())
83            .unwrap_or("")
84            .to_string();
85        match action.as_str() {
86            "create" => self.create(input, chat_id).await,
87            "list" => match call_blocking(self.db.clone(), move |db| {
88                db.list_learning_tracks(Some(chat_id))
89            })
90            .await
91            {
92                Ok(tracks) => ToolResult::success(
93                    serde_json::to_string_pretty(&tracks).unwrap_or_else(|_| "[]".into()),
94                ),
95                Err(error) => ToolResult::error(error.to_string()),
96            },
97            "pause" | "resume" | "archive" | "run_now" => {
98                self.change_status(input, chat_id, &action).await
99            }
100            _ => ToolResult::error("invalid learning_tracks action".into()),
101        }
102    }
103}
104
105impl LearningTracksTool {
106    async fn create(&self, input: serde_json::Value, chat_id: i64) -> ToolResult {
107        let Some(name) = input.get("name").and_then(|value| value.as_str()) else {
108            return ToolResult::error("create requires name".into());
109        };
110        let Some(objective) = input.get("objective").and_then(|value| value.as_str()) else {
111            return ToolResult::error("create requires objective".into());
112        };
113        let directions = input
114            .get("directions")
115            .cloned()
116            .unwrap_or_else(|| json!([]));
117        let sources = input
118            .get("allowed_sources")
119            .cloned()
120            .unwrap_or_else(|| json!(["official_docs", "peer_reviewed_papers"]));
121        if !directions.is_array() || !sources.is_array() {
122            return ToolResult::error("directions and allowed_sources must be arrays".into());
123        }
124        let schedule = input
125            .get("schedule")
126            .and_then(|value| value.as_str())
127            .unwrap_or("0 0 3 * * *");
128        let timezone = input
129            .get("timezone")
130            .and_then(|value| value.as_str())
131            .unwrap_or(&self.default_timezone);
132        let next_run = match crate::learning_foundry::compute_next_learning_run(schedule, timezone)
133        {
134            Ok(next_run) => next_run,
135            Err(error) => return ToolResult::error(error),
136        };
137        let token_budget = input
138            .get("token_budget")
139            .and_then(|value| value.as_i64())
140            .unwrap_or(80_000);
141        let max_sources = input
142            .get("max_sources")
143            .and_then(|value| value.as_i64())
144            .unwrap_or(20);
145        let promotion_mode = input
146            .get("promotion_mode")
147            .and_then(|value| value.as_str())
148            .unwrap_or("propose");
149        let name = name.to_string();
150        let objective = objective.to_string();
151        let schedule = schedule.to_string();
152        let timezone = timezone.to_string();
153        let promotion_mode = promotion_mode.to_string();
154        let persisted_next_run = next_run.clone();
155        match call_blocking(self.db.clone(), move |db| {
156            db.create_learning_track(
157                chat_id,
158                &name,
159                &objective,
160                &directions,
161                &sources,
162                &schedule,
163                &timezone,
164                token_budget,
165                max_sources,
166                &promotion_mode,
167                &persisted_next_run,
168            )
169        })
170        .await
171        {
172            Ok(track_id) => ToolResult::success(format!(
173                "Learning track created: {track_id}. It will run at {next_run} and produce review-only candidates."
174            )),
175            Err(error) => ToolResult::error(error.to_string()),
176        }
177    }
178
179    async fn change_status(
180        &self,
181        input: serde_json::Value,
182        chat_id: i64,
183        action: &str,
184    ) -> ToolResult {
185        let Some(track_id) = input.get("track_id").and_then(|value| value.as_str()) else {
186            return ToolResult::error(format!("{action} requires track_id"));
187        };
188        let track_id = track_id.to_string();
189        let belongs = call_blocking(self.db.clone(), {
190            let track_id = track_id.clone();
191            move |db| db.learning_track_belongs_to_chat(&track_id, chat_id)
192        })
193        .await;
194        if !matches!(belongs, Ok(true)) {
195            return ToolResult::error("learning track not found in this chat".into());
196        }
197        let (status, next_run) = match action {
198            "pause" => ("paused", None),
199            "archive" => ("archived", None),
200            "resume" => ("active", None),
201            "run_now" => ("active", Some(chrono::Utc::now().to_rfc3339())),
202            _ => unreachable!(),
203        };
204        let status = status.to_string();
205        match call_blocking(self.db.clone(), move |db| {
206            db.update_learning_track_status(&track_id, &status, next_run.as_deref())
207        })
208        .await
209        {
210            Ok(true) => ToolResult::success(format!("Learning track {action} accepted.")),
211            Ok(false) => ToolResult::error("learning track not found".into()),
212            Err(error) => ToolResult::error(error.to_string()),
213        }
214    }
215}