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}