cobolx 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1345 @@
1
+ use crate::config::ConfigManager;
2
+ use crate::memory::MemoryStore;
3
+ use crate::ui::tui::Message;
4
+ use serde::{Deserialize, Serialize};
5
+ use std::path::Path;
6
+
7
+ #[derive(Serialize, Deserialize, Clone, Debug)]
8
+ pub struct ChatMessage {
9
+ pub role: String,
10
+ #[serde(skip_serializing_if = "Option::is_none")]
11
+ pub content: Option<String>,
12
+ #[serde(skip_serializing_if = "Option::is_none")]
13
+ pub tool_call_id: Option<String>,
14
+ #[serde(skip_serializing_if = "Option::is_none")]
15
+ pub tool_calls: Option<Vec<ToolCall>>,
16
+ }
17
+
18
+ #[derive(Serialize, Deserialize, Clone, Debug)]
19
+ pub struct ToolCall {
20
+ pub id: String,
21
+ pub r#type: String,
22
+ pub function: FunctionCall,
23
+ }
24
+
25
+ #[derive(Serialize, Deserialize, Clone, Debug)]
26
+ pub struct FunctionCall {
27
+ pub name: String,
28
+ pub arguments: String,
29
+ }
30
+
31
+ #[derive(Serialize, Clone, Debug)]
32
+ pub struct Tool {
33
+ pub r#type: String,
34
+ pub function: FunctionDefinition,
35
+ }
36
+
37
+ #[derive(Serialize, Clone, Debug)]
38
+ pub struct FunctionDefinition {
39
+ pub name: String,
40
+ pub description: String,
41
+ pub parameters: serde_json::Value,
42
+ }
43
+
44
+ #[derive(Serialize)]
45
+ struct StreamOptions {
46
+ include_usage: bool,
47
+ }
48
+
49
+ #[derive(Serialize)]
50
+ struct ChatRequest {
51
+ model: String,
52
+ messages: Vec<ChatMessage>,
53
+ stream: bool,
54
+ #[serde(skip_serializing_if = "Option::is_none")]
55
+ temperature: Option<f32>,
56
+ #[serde(skip_serializing_if = "Option::is_none")]
57
+ stream_options: Option<StreamOptions>,
58
+ #[serde(skip_serializing_if = "Option::is_none")]
59
+ tools: Option<Vec<Tool>>,
60
+ }
61
+
62
+ #[derive(Deserialize, Debug)]
63
+ #[allow(dead_code)]
64
+ struct ChatResponseChoiceMessage {
65
+ #[serde(default)]
66
+ content: Option<String>,
67
+ #[serde(default)]
68
+ tool_calls: Option<Vec<ToolCall>>,
69
+ }
70
+
71
+ #[derive(Deserialize)]
72
+ struct ChatResponseChoice {
73
+ message: ChatResponseChoiceMessage,
74
+ }
75
+
76
+ #[derive(Deserialize, Debug, Clone)]
77
+ struct ToolCallDelta {
78
+ index: usize,
79
+ id: Option<String>,
80
+ r#type: Option<String>,
81
+ function: Option<FunctionCallDelta>,
82
+ }
83
+
84
+ #[derive(Deserialize, Debug, Clone)]
85
+ struct FunctionCallDelta {
86
+ name: Option<String>,
87
+ arguments: Option<String>,
88
+ }
89
+
90
+ #[derive(Deserialize)]
91
+ struct ChatResponseStreamChoiceDelta {
92
+ #[serde(default)]
93
+ content: Option<String>,
94
+ #[serde(default)]
95
+ tool_calls: Option<Vec<ToolCallDelta>>,
96
+ }
97
+
98
+ #[derive(Deserialize)]
99
+ struct ChatResponseStreamChoice {
100
+ delta: ChatResponseStreamChoiceDelta,
101
+ }
102
+
103
+ #[derive(Deserialize, Clone, Default, Debug)]
104
+ pub struct Usage {
105
+ pub prompt_tokens: u32,
106
+ pub completion_tokens: u32,
107
+ #[allow(dead_code)]
108
+ pub total_tokens: u32,
109
+ }
110
+
111
+ #[derive(Deserialize)]
112
+ struct ChatResponseStream {
113
+ choices: Vec<ChatResponseStreamChoice>,
114
+ #[serde(default)]
115
+ usage: Option<Usage>,
116
+ }
117
+
118
+ #[derive(Deserialize)]
119
+ struct ChatResponse {
120
+ choices: Vec<ChatResponseChoice>,
121
+ }
122
+
123
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
124
+ pub enum Route {
125
+ Light,
126
+ Heavy,
127
+ Database,
128
+ Filesystem,
129
+ }
130
+
131
+ pub struct DeepSeekClient {
132
+ api_key: String,
133
+ http_client: reqwest::Client,
134
+ }
135
+
136
+ impl DeepSeekClient {
137
+ pub fn new(api_key: String) -> Self {
138
+ Self {
139
+ api_key,
140
+ http_client: reqwest::Client::new(),
141
+ }
142
+ }
143
+
144
+ pub async fn call_api(
145
+ &self,
146
+ messages: &[ChatMessage],
147
+ temperature: Option<f32>,
148
+ ) -> Result<String, String> {
149
+ let request_body = ChatRequest {
150
+ model: "deepseek-chat".to_string(),
151
+ messages: messages.to_vec(),
152
+ stream: false,
153
+ temperature,
154
+ stream_options: None,
155
+ tools: None,
156
+ };
157
+
158
+ let response = self
159
+ .http_client
160
+ .post("https://api.deepseek.com/chat/completions")
161
+ .header("Authorization", format!("Bearer {}", self.api_key))
162
+ .json(&request_body)
163
+ .send()
164
+ .await
165
+ .map_err(|e| format!("Network error: {}", e))?;
166
+
167
+ if !response.status().is_success() {
168
+ let err_body = response.text().await.unwrap_or_default();
169
+ return Err(format!("DeepSeek API error: {}", err_body));
170
+ }
171
+
172
+ let result: ChatResponse = response
173
+ .json()
174
+ .await
175
+ .map_err(|e| format!("Failed to parse JSON: {}", e))?;
176
+
177
+ if let Some(choice) = result.choices.first() {
178
+ Ok(choice.message.content.clone().unwrap_or_default())
179
+ } else {
180
+ Err("No completion choices returned".to_string())
181
+ }
182
+ }
183
+
184
+ pub async fn call_api_stream(
185
+ &self,
186
+ messages: &[ChatMessage],
187
+ temperature: Option<f32>,
188
+ tx: tokio::sync::mpsc::UnboundedSender<String>,
189
+ ) -> Result<Option<Usage>, String> {
190
+ let request_body = ChatRequest {
191
+ model: "deepseek-chat".to_string(),
192
+ messages: messages.to_vec(),
193
+ stream: true,
194
+ temperature,
195
+ stream_options: Some(StreamOptions {
196
+ include_usage: true,
197
+ }),
198
+ tools: None,
199
+ };
200
+
201
+ let response = self
202
+ .http_client
203
+ .post("https://api.deepseek.com/chat/completions")
204
+ .header("Authorization", format!("Bearer {}", self.api_key))
205
+ .json(&request_body)
206
+ .send()
207
+ .await
208
+ .map_err(|e| format!("Network error: {}", e))?;
209
+
210
+ if !response.status().is_success() {
211
+ let err_body = response.text().await.unwrap_or_default();
212
+ return Err(format!("DeepSeek API error: {}", err_body));
213
+ }
214
+
215
+ use futures_util::StreamExt;
216
+ let mut stream = response.bytes_stream();
217
+ let mut buffer = String::new();
218
+ let mut final_usage = None;
219
+
220
+ while let Some(chunk_res) = stream.next().await {
221
+ let chunk = chunk_res.map_err(|e| format!("Stream read error: {}", e))?;
222
+ let chunk_str = String::from_utf8_lossy(&chunk);
223
+ buffer.push_str(&chunk_str);
224
+
225
+ while let Some(pos) = buffer.find('\n') {
226
+ let line = buffer[..pos].to_string();
227
+ buffer.drain(..=pos);
228
+
229
+ let trimmed = line.trim();
230
+ if trimmed.is_empty() {
231
+ continue;
232
+ }
233
+ if trimmed == "data: [DONE]" {
234
+ break;
235
+ }
236
+ if let Some(json_str) = trimmed.strip_prefix("data: ") {
237
+ if let Ok(parsed) = serde_json::from_str::<ChatResponseStream>(json_str) {
238
+ if let Some(ref usage) = parsed.usage {
239
+ final_usage = Some(usage.clone());
240
+ }
241
+ if let Some(choice) = parsed.choices.first() {
242
+ if let Some(ref content) = choice.delta.content {
243
+ if !content.is_empty() {
244
+ let _ = tx.send(content.clone());
245
+ }
246
+ }
247
+ }
248
+ }
249
+ }
250
+ }
251
+ }
252
+ Ok(final_usage)
253
+ }
254
+ }
255
+
256
+ pub struct GlmClient {
257
+ api_key: String,
258
+ http_client: reqwest::Client,
259
+ }
260
+
261
+ impl GlmClient {
262
+ pub fn new(api_key: String) -> Self {
263
+ Self {
264
+ api_key,
265
+ http_client: reqwest::Client::new(),
266
+ }
267
+ }
268
+
269
+ pub async fn call_api(
270
+ &self,
271
+ messages: &[ChatMessage],
272
+ temperature: Option<f32>,
273
+ ) -> Result<String, String> {
274
+ let request_body = ChatRequest {
275
+ model: "glm-4-pro".to_string(),
276
+ messages: messages.to_vec(),
277
+ stream: false,
278
+ temperature,
279
+ stream_options: None,
280
+ tools: None,
281
+ };
282
+
283
+ let response = self
284
+ .http_client
285
+ .post("https://open.bigmodel.cn/api/paas/v4/chat/completions")
286
+ .header("Authorization", format!("Bearer {}", self.api_key))
287
+ .json(&request_body)
288
+ .send()
289
+ .await
290
+ .map_err(|e| format!("Network error: {}", e))?;
291
+
292
+ if !response.status().is_success() {
293
+ let err_body = response.text().await.unwrap_or_default();
294
+ return Err(format!("GLM-4-Pro API error: {}", err_body));
295
+ }
296
+
297
+ let result: ChatResponse = response
298
+ .json()
299
+ .await
300
+ .map_err(|e| format!("Failed to parse JSON: {}", e))?;
301
+
302
+ if let Some(choice) = result.choices.first() {
303
+ Ok(choice.message.content.clone().unwrap_or_default())
304
+ } else {
305
+ Err("No completion choices returned".to_string())
306
+ }
307
+ }
308
+
309
+ pub async fn call_api_stream(
310
+ &self,
311
+ messages: &[ChatMessage],
312
+ temperature: Option<f32>,
313
+ tx: tokio::sync::mpsc::UnboundedSender<String>,
314
+ ) -> Result<Option<Usage>, String> {
315
+ let request_body = ChatRequest {
316
+ model: "glm-4-pro".to_string(),
317
+ messages: messages.to_vec(),
318
+ stream: true,
319
+ temperature,
320
+ stream_options: Some(StreamOptions {
321
+ include_usage: true,
322
+ }),
323
+ tools: None,
324
+ };
325
+
326
+ let response = self
327
+ .http_client
328
+ .post("https://open.bigmodel.cn/api/paas/v4/chat/completions")
329
+ .header("Authorization", format!("Bearer {}", self.api_key))
330
+ .json(&request_body)
331
+ .send()
332
+ .await
333
+ .map_err(|e| format!("Network error: {}", e))?;
334
+
335
+ if !response.status().is_success() {
336
+ let err_body = response.text().await.unwrap_or_default();
337
+ return Err(format!("GLM-4-Pro API error: {}", err_body));
338
+ }
339
+
340
+ use futures_util::StreamExt;
341
+ let mut stream = response.bytes_stream();
342
+ let mut buffer = String::new();
343
+ let mut final_usage = None;
344
+
345
+ while let Some(chunk_res) = stream.next().await {
346
+ let chunk = chunk_res.map_err(|e| format!("Stream read error: {}", e))?;
347
+ let chunk_str = String::from_utf8_lossy(&chunk);
348
+ buffer.push_str(&chunk_str);
349
+
350
+ while let Some(pos) = buffer.find('\n') {
351
+ let line = buffer[..pos].to_string();
352
+ buffer.drain(..=pos);
353
+
354
+ let trimmed = line.trim();
355
+ if trimmed.is_empty() {
356
+ continue;
357
+ }
358
+ if trimmed == "data: [DONE]" {
359
+ break;
360
+ }
361
+ if let Some(json_str) = trimmed.strip_prefix("data: ") {
362
+ if let Ok(parsed) = serde_json::from_str::<ChatResponseStream>(json_str) {
363
+ if let Some(ref usage) = parsed.usage {
364
+ final_usage = Some(usage.clone());
365
+ }
366
+ if let Some(choice) = parsed.choices.first() {
367
+ if let Some(ref content) = choice.delta.content {
368
+ if !content.is_empty() {
369
+ let _ = tx.send(content.clone());
370
+ }
371
+ }
372
+ }
373
+ }
374
+ }
375
+ }
376
+ }
377
+ Ok(final_usage)
378
+ }
379
+ }
380
+
381
+ fn merge_tool_call_deltas(existing: &mut Vec<ToolCall>, deltas: Vec<ToolCallDelta>) {
382
+ for delta in deltas {
383
+ let idx = delta.index;
384
+ while existing.len() <= idx {
385
+ existing.push(ToolCall {
386
+ id: String::new(),
387
+ r#type: "function".to_string(),
388
+ function: FunctionCall {
389
+ name: String::new(),
390
+ arguments: String::new(),
391
+ },
392
+ });
393
+ }
394
+ let tc = &mut existing[idx];
395
+ if let Some(id) = delta.id {
396
+ tc.id.push_str(&id);
397
+ }
398
+ if let Some(r#type) = delta.r#type {
399
+ tc.r#type = r#type;
400
+ }
401
+ if let Some(func) = delta.function {
402
+ if let Some(name) = func.name {
403
+ tc.function.name.push_str(&name);
404
+ }
405
+ if let Some(args) = func.arguments {
406
+ tc.function.arguments.push_str(&args);
407
+ }
408
+ }
409
+ }
410
+ }
411
+
412
+ pub struct AgentRouter {
413
+ deepseek: Option<DeepSeekClient>,
414
+ glm: Option<GlmClient>,
415
+ pub config_path: Option<String>,
416
+ }
417
+
418
+ impl AgentRouter {
419
+ pub fn new() -> Self {
420
+ let env_deepseek = std::env::var("DEEPSEEK_API_KEY")
421
+ .ok()
422
+ .filter(|k| !k.trim().is_empty());
423
+
424
+ let env_glm = std::env::var("GLM_API_KEY")
425
+ .ok()
426
+ .filter(|k| !k.trim().is_empty());
427
+
428
+ let (config_path_str, config_data) = ConfigManager::load_or_create();
429
+
430
+ let file_deepseek =
431
+ Some(config_data.deepseek_api_key.trim().to_string()).filter(|k| !k.is_empty());
432
+ let file_glm = Some(config_data.glm_api_key.trim().to_string()).filter(|k| !k.is_empty());
433
+
434
+ let final_deepseek = env_deepseek.or(file_deepseek);
435
+ let final_glm = env_glm.or(file_glm);
436
+
437
+ let deepseek = final_deepseek.map(DeepSeekClient::new);
438
+ let glm = final_glm.map(GlmClient::new);
439
+
440
+ Self {
441
+ deepseek,
442
+ glm,
443
+ config_path: config_path_str,
444
+ }
445
+ }
446
+
447
+ pub fn has_credentials(&self) -> bool {
448
+ self.deepseek.is_some() || self.glm.is_some()
449
+ }
450
+
451
+ /// Classifies user input by spawning a Router Sub-Agent
452
+ pub async fn classify_route(&self, prompt: &str) -> Route {
453
+ // Router system instructions
454
+ let system_msg = ChatMessage {
455
+ role: "system".to_string(),
456
+ content: Some("You are the Routing Sub-Agent. Your task is to analyze the user's query and classify it into one of four categories:\n\
457
+ - 'LIGHT': simple greetings, basic questions, short chat, definitions.\n\
458
+ - 'HEAVY': programming/coding questions, algorithm writing, complex logic, mathematics, system architecture, deep analysis.\n\
459
+ - 'DATABASE': questions asking about the COBOL project structure, file counts, copybook references, call graphs, or data variables/layout inside the workspace database.\n\
460
+ - 'FILESYSTEM': requests to read, open, or show the actual source content of a COBOL file or copybook; requests to write, generate, or create a new code file; requests to search for text patterns inside files; requests to list directory contents; any file migration or refactoring task that requires reading/writing file content directly.\n\
461
+ You MUST output exactly one word: 'LIGHT', 'HEAVY', 'DATABASE', or 'FILESYSTEM'. Do not include any punctuation or extra text.".to_string()),
462
+ tool_call_id: None,
463
+ tool_calls: None,
464
+ };
465
+ let user_msg = ChatMessage {
466
+ role: "user".to_string(),
467
+ content: Some(prompt.to_string()),
468
+ tool_call_id: None,
469
+ tool_calls: None,
470
+ };
471
+ let messages = vec![system_msg, user_msg];
472
+
473
+ // Call the routing model (prefer DeepSeek as it's fast/cheap; fallback to GLM if DeepSeek is missing)
474
+ let response = if let Some(ref ds) = self.deepseek {
475
+ ds.call_api(&messages, Some(0.0)).await // temperature 0 for strict classification
476
+ } else if let Some(ref g) = self.glm {
477
+ g.call_api(&messages, Some(0.0)).await
478
+ } else {
479
+ return Route::Light;
480
+ };
481
+
482
+ match response {
483
+ Ok(content) => {
484
+ let trimmed = content.trim().to_uppercase();
485
+ if trimmed.contains("FILESYSTEM") {
486
+ Route::Filesystem
487
+ } else if trimmed.contains("DATABASE") {
488
+ Route::Database
489
+ } else if trimmed.contains("HEAVY") {
490
+ Route::Heavy
491
+ } else {
492
+ Route::Light
493
+ }
494
+ }
495
+ Err(_) => Route::Light,
496
+ }
497
+ }
498
+
499
+ /// Dispatches prompt with dialog history memory to the selected sub-agent
500
+ #[allow(dead_code)]
501
+ pub async fn execute_chat(
502
+ &self,
503
+ history: &[Message],
504
+ route: Route,
505
+ _sandbox_path: Option<&Path>,
506
+ ) -> Result<(String, &'static str), String> {
507
+ let mut messages = Vec::new();
508
+
509
+ // System prompt defining COBOLX identity
510
+ messages.push(ChatMessage {
511
+ role: "system".to_string(),
512
+ content: Some("You are COBOLX, a helpful assistant. COBOLX is a migration agent for legacy COBOL systems based on DeepSeek.".to_string()),
513
+ tool_call_id: None,
514
+ tool_calls: None,
515
+ });
516
+
517
+ // Convert TUI local history into model messages (Memory)
518
+ for msg in history {
519
+ let role = match msg.sender {
520
+ crate::ui::tui::Sender::User => "user".to_string(),
521
+ crate::ui::tui::Sender::Cobolx => "assistant".to_string(),
522
+ };
523
+ // Skip mock response text headers or placeholders
524
+ if msg.text.starts_with("Received prompt:")
525
+ || msg.text == "Thinking..."
526
+ || msg.text.starts_with("Routing...")
527
+ || msg.text.starts_with("(Routed:")
528
+ {
529
+ continue;
530
+ }
531
+ let mut content = msg.text.clone();
532
+ if content.starts_with("(Using ") {
533
+ if let Some(idx) = content.find(") ") {
534
+ content = content[idx + 2..].to_string();
535
+ }
536
+ }
537
+ messages.push(ChatMessage {
538
+ role,
539
+ content: Some(content),
540
+ tool_call_id: None,
541
+ tool_calls: None,
542
+ });
543
+ }
544
+
545
+ match route {
546
+ Route::Light => {
547
+ if let Some(ref ds) = self.deepseek {
548
+ let res = ds.call_api(&messages, None).await;
549
+ res.map(|text| (text, "DeepSeek"))
550
+ } else if let Some(ref g) = self.glm {
551
+ let res = g.call_api(&messages, None).await;
552
+ res.map(|text| (text, "GLM-4-Pro (Fallback)"))
553
+ } else {
554
+ Err(
555
+ "No API client initialized. Set DEEPSEEK_API_KEY or GLM_API_KEY."
556
+ .to_string(),
557
+ )
558
+ }
559
+ }
560
+ Route::Heavy => {
561
+ if let Some(ref g) = self.glm {
562
+ let res = g.call_api(&messages, None).await;
563
+ res.map(|text| (text, "GLM-4-Pro"))
564
+ } else if let Some(ref ds) = self.deepseek {
565
+ let res = ds.call_api(&messages, None).await;
566
+ res.map(|text| (text, "DeepSeek (Fallback)"))
567
+ } else {
568
+ Err(
569
+ "No API client initialized. Set DEEPSEEK_API_KEY or GLM_API_KEY."
570
+ .to_string(),
571
+ )
572
+ }
573
+ }
574
+ Route::Database | Route::Filesystem => {
575
+ Err("This route is only supported in streaming mode.".to_string())
576
+ }
577
+ }
578
+ }
579
+
580
+ /// Dispatches prompt with dialog history memory to the selected sub-agent as a stream
581
+ pub async fn execute_chat_stream(
582
+ &self,
583
+ history: &[Message],
584
+ route: Route,
585
+ sandbox_path: Option<&Path>,
586
+ tx: tokio::sync::mpsc::UnboundedSender<String>,
587
+ ) -> Result<(Option<Usage>, &'static str), String> {
588
+ let mut messages = Vec::new();
589
+
590
+ // System prompt defining COBOLX identity
591
+ messages.push(ChatMessage {
592
+ role: "system".to_string(),
593
+ content: Some("You are COBOLX, a helpful assistant. COBOLX is a migration agent for legacy COBOL systems based on DeepSeek.".to_string()),
594
+ tool_call_id: None,
595
+ tool_calls: None,
596
+ });
597
+
598
+ // Convert TUI local history into model messages (Memory)
599
+ for msg in history {
600
+ let role = match msg.sender {
601
+ crate::ui::tui::Sender::User => "user".to_string(),
602
+ crate::ui::tui::Sender::Cobolx => "assistant".to_string(),
603
+ };
604
+ // Skip mock response text headers or placeholders
605
+ if msg.text.starts_with("Received prompt:")
606
+ || msg.text == "Thinking..."
607
+ || msg.text.starts_with("Routing...")
608
+ || msg.text.starts_with("(Routed:")
609
+ {
610
+ continue;
611
+ }
612
+ let mut content = msg.text.clone();
613
+ if content.starts_with("(Using ") {
614
+ if let Some(idx) = content.find(") ") {
615
+ content = content[idx + 2..].to_string();
616
+ }
617
+ }
618
+ messages.push(ChatMessage {
619
+ role,
620
+ content: Some(content),
621
+ tool_call_id: None,
622
+ tool_calls: None,
623
+ });
624
+ }
625
+
626
+ match route {
627
+ Route::Light => {
628
+ if let Some(ref ds) = self.deepseek {
629
+ let res = ds.call_api_stream(&messages, None, tx).await;
630
+ res.map(|u| (u, "DeepSeek"))
631
+ } else if let Some(ref g) = self.glm {
632
+ let res = g.call_api_stream(&messages, None, tx).await;
633
+ res.map(|u| (u, "GLM-4-Pro (Fallback)"))
634
+ } else {
635
+ Err(
636
+ "No API client initialized. Set DEEPSEEK_API_KEY or GLM_API_KEY."
637
+ .to_string(),
638
+ )
639
+ }
640
+ }
641
+ Route::Heavy => {
642
+ if let Some(ref g) = self.glm {
643
+ let res = g.call_api_stream(&messages, None, tx).await;
644
+ res.map(|u| (u, "GLM-4-Pro"))
645
+ } else if let Some(ref ds) = self.deepseek {
646
+ let res = ds.call_api_stream(&messages, None, tx).await;
647
+ res.map(|u| (u, "DeepSeek (Fallback)"))
648
+ } else {
649
+ Err(
650
+ "No API client initialized. Set DEEPSEEK_API_KEY or GLM_API_KEY."
651
+ .to_string(),
652
+ )
653
+ }
654
+ }
655
+ Route::Database => {
656
+ let Some(path) = sandbox_path else {
657
+ return Err("Database query requires a configured sandbox path.".to_string());
658
+ };
659
+ let model_name = if self.glm.is_some() {
660
+ "GLM-4-Pro (Database Sub-Agent)"
661
+ } else {
662
+ "DeepSeek (Database Sub-Agent)"
663
+ };
664
+ let res = self.run_database_agent_stream(&messages, path, tx).await;
665
+ res.map(|u| (u, model_name))
666
+ }
667
+ Route::Filesystem => {
668
+ let Some(path) = sandbox_path else {
669
+ return Err(
670
+ "Filesystem operations require a configured sandbox path.".to_string()
671
+ );
672
+ };
673
+ let model_name = if self.glm.is_some() {
674
+ "GLM-4-Pro (Filesystem Sub-Agent)"
675
+ } else {
676
+ "DeepSeek (Filesystem Sub-Agent)"
677
+ };
678
+ let res = self.run_filesystem_agent_stream(&messages, path, tx).await;
679
+ res.map(|u| (u, model_name))
680
+ }
681
+ }
682
+ }
683
+
684
+ async fn run_database_agent_stream(
685
+ &self,
686
+ initial_messages: &[ChatMessage],
687
+ sandbox_path: &Path,
688
+ tx: tokio::sync::mpsc::UnboundedSender<String>,
689
+ ) -> Result<Option<Usage>, String> {
690
+ let (api_key, api_url, model_name) = if let Some(ref g) = self.glm {
691
+ (
692
+ g.api_key.clone(),
693
+ "https://open.bigmodel.cn/api/paas/v4/chat/completions",
694
+ "glm-4-pro",
695
+ )
696
+ } else if let Some(ref ds) = self.deepseek {
697
+ (
698
+ ds.api_key.clone(),
699
+ "https://api.deepseek.com/chat/completions",
700
+ "deepseek-chat",
701
+ )
702
+ } else {
703
+ return Err("No API client initialized for Database Sub-Agent.".to_string());
704
+ };
705
+
706
+ let http_client = reqwest::Client::new();
707
+ let mut messages = initial_messages.to_vec();
708
+
709
+ // Update system message
710
+ if let Some(first_msg) = messages.get_mut(0) {
711
+ if first_msg.role == "system" {
712
+ first_msg.content = Some("You are the COBOLX Database Sub-Agent. Your task is to help the user analyze their COBOL codebase by querying the local SQLite database. You have access to the `query_sqlite` tool to execute read-only SELECT queries.\n\
713
+ Database Schema:\n\
714
+ 1. `files` (id INTEGER PRIMARY KEY, path TEXT, kind TEXT ('source' or 'copybook'), size_bytes INTEGER, mtime_unix INTEGER)\n\
715
+ 2. `programs` (id INTEGER PRIMARY KEY, name TEXT, file_id INTEGER, start_offset INTEGER, byte_len INTEGER) - COBOL programs.\n\
716
+ 3. `copybook_uses` (id INTEGER PRIMARY KEY, from_file_id INTEGER, copybook_name TEXT, start_offset INTEGER, byte_len INTEGER, resolved_file_id INTEGER, resolve_status TEXT ('resolved', 'missing'), replacing_text TEXT) - COPY book tracking.\n\
717
+ 4. `call_edges` (id INTEGER PRIMARY KEY, caller_program_id INTEGER, callee_name TEXT, start_offset INTEGER, byte_len INTEGER, kind TEXT ('static', 'dynamic'), using_count INTEGER) - CALL graphs.\n\
718
+ 5. `data_items` (id INTEGER PRIMARY KEY, program_id INTEGER, source_file_id INTEGER, name TEXT, level INTEGER, parent_name TEXT, pic TEXT, usage_clause TEXT, occurs INTEGER, redefines TEXT, section TEXT, byte_offset INTEGER, byte_size INTEGER, storage_kind TEXT, layout_status TEXT, start_offset INTEGER, byte_len INTEGER) - variable details.\n\n\
719
+ GUIDELINES:\n\
720
+ - Write standard SELECT queries to run on SQLite.\n\
721
+ - Make sure the SQL is correct and only executes read-only SELECT statements.\n\
722
+ - If unsure what table columns are, perform queries to check them first.\n\
723
+ - Explain the answers clearly. If no data matches, explain that to the user.".to_string());
724
+ }
725
+ }
726
+
727
+ let query_sqlite_tool = Tool {
728
+ r#type: "function".to_string(),
729
+ function: FunctionDefinition {
730
+ name: "query_sqlite".to_string(),
731
+ description: "Run a read-only SELECT query against the local SQLite database indexing the COBOL project structure.".to_string(),
732
+ parameters: serde_json::json!({
733
+ "type": "object",
734
+ "properties": {
735
+ "sql": {
736
+ "type": "string",
737
+ "description": "The SQLite SELECT statement to execute."
738
+ }
739
+ },
740
+ "required": ["sql"]
741
+ }),
742
+ },
743
+ };
744
+ let tools = vec![query_sqlite_tool];
745
+
746
+ let mut final_usage = Usage::default();
747
+
748
+ for _turn in 0..5 {
749
+ let request_body = ChatRequest {
750
+ model: model_name.to_string(),
751
+ messages: messages.clone(),
752
+ stream: true,
753
+ temperature: Some(0.0),
754
+ stream_options: Some(StreamOptions {
755
+ include_usage: true,
756
+ }),
757
+ tools: Some(tools.clone()),
758
+ };
759
+
760
+ let response = http_client
761
+ .post(api_url)
762
+ .header("Authorization", format!("Bearer {}", api_key))
763
+ .json(&request_body)
764
+ .send()
765
+ .await
766
+ .map_err(|e| format!("Network error: {}", e))?;
767
+
768
+ if !response.status().is_success() {
769
+ let err_body = response.text().await.unwrap_or_default();
770
+ return Err(format!("Database Sub-Agent API error: {}", err_body));
771
+ }
772
+
773
+ use futures_util::StreamExt;
774
+ let mut stream = response.bytes_stream();
775
+ let mut buffer = String::new();
776
+ let mut tool_calls_accumulated: Vec<ToolCall> = Vec::new();
777
+
778
+ while let Some(chunk_res) = stream.next().await {
779
+ let chunk = chunk_res.map_err(|e| format!("Stream read error: {}", e))?;
780
+ let chunk_str = String::from_utf8_lossy(&chunk);
781
+ buffer.push_str(&chunk_str);
782
+
783
+ while let Some(pos) = buffer.find('\n') {
784
+ let line = buffer[..pos].to_string();
785
+ buffer.drain(..=pos);
786
+
787
+ let trimmed = line.trim();
788
+ if trimmed.is_empty() {
789
+ continue;
790
+ }
791
+ if trimmed == "data: [DONE]" {
792
+ break;
793
+ }
794
+ if let Some(json_str) = trimmed.strip_prefix("data: ") {
795
+ if let Ok(parsed) = serde_json::from_str::<ChatResponseStream>(json_str) {
796
+ if let Some(ref usage) = parsed.usage {
797
+ final_usage.prompt_tokens += usage.prompt_tokens;
798
+ final_usage.completion_tokens += usage.completion_tokens;
799
+ final_usage.total_tokens += usage.total_tokens;
800
+ }
801
+ if let Some(choice) = parsed.choices.first() {
802
+ if let Some(ref content) = choice.delta.content {
803
+ if !content.is_empty() {
804
+ let _ = tx.send(content.clone());
805
+ }
806
+ }
807
+ if let Some(ref deltas) = choice.delta.tool_calls {
808
+ merge_tool_call_deltas(
809
+ &mut tool_calls_accumulated,
810
+ deltas.clone(),
811
+ );
812
+ }
813
+ }
814
+ }
815
+ }
816
+ }
817
+ }
818
+
819
+ if !tool_calls_accumulated.is_empty() {
820
+ let _ = tx.send(
821
+ "\x01STATUS:Using Database Sub-Agent: Querying SQLite database...".to_string(),
822
+ );
823
+
824
+ let assistant_msg = ChatMessage {
825
+ role: "assistant".to_string(),
826
+ content: None,
827
+ tool_call_id: None,
828
+ tool_calls: Some(tool_calls_accumulated.clone()),
829
+ };
830
+ messages.push(assistant_msg);
831
+
832
+ let store = MemoryStore::open_or_create(sandbox_path)
833
+ .map_err(|e| format!("Failed to open memory store: {}", e))?;
834
+
835
+ for tc in &tool_calls_accumulated {
836
+ if tc.function.name == "query_sqlite" {
837
+ let parsed_args: serde_json::Value =
838
+ serde_json::from_str(&tc.function.arguments).map_err(|e| {
839
+ format!("Failed to parse function arguments: {}", e)
840
+ })?;
841
+
842
+ let sql = parsed_args
843
+ .get("sql")
844
+ .and_then(|v| v.as_str())
845
+ .unwrap_or("");
846
+
847
+ let db_result = match store.query_readonly(sql) {
848
+ Ok(json_val) => json_val.to_string(),
849
+ Err(err) => serde_json::json!({
850
+ "error": err.to_string()
851
+ })
852
+ .to_string(),
853
+ };
854
+
855
+ let tool_msg = ChatMessage {
856
+ role: "tool".to_string(),
857
+ content: Some(db_result),
858
+ tool_call_id: Some(tc.id.clone()),
859
+ tool_calls: None,
860
+ };
861
+ messages.push(tool_msg);
862
+ }
863
+ }
864
+ let _ = tx.send("\x01STATUS:".to_string());
865
+ } else {
866
+ break;
867
+ }
868
+ }
869
+
870
+ let _ = tx.send("\x01STATUS:".to_string());
871
+ Ok(Some(final_usage))
872
+ }
873
+
874
+ /// Validates that `user_path` resolves to a location inside `sandbox`.
875
+ /// Returns the canonical absolute path if safe, or an error string.
876
+ fn validate_sandbox_path(
877
+ sandbox: &Path,
878
+ user_path: &str,
879
+ ) -> Result<std::path::PathBuf, String> {
880
+ // Strip leading separators from non-absolute paths so that an LLM-generated
881
+ // path like "/docs/README.md" is treated as "docs/README.md" relative to the
882
+ // sandbox root, rather than escaping to the drive root on Windows.
883
+ let normalized = if std::path::Path::new(user_path).is_absolute() {
884
+ user_path.to_string()
885
+ } else {
886
+ user_path.trim_start_matches(['/', '\\']).to_string()
887
+ };
888
+
889
+ let candidate = if std::path::Path::new(&normalized).is_absolute() {
890
+ std::path::PathBuf::from(&normalized)
891
+ } else {
892
+ sandbox.join(&normalized)
893
+ };
894
+
895
+ // Resolve the sandbox root first so the comparison is reliable even if the
896
+ // sandbox path itself contains symlinks.
897
+ let sandbox_canon = sandbox
898
+ .canonicalize()
899
+ .map_err(|e| format!("Sandbox path error: {e}"))?;
900
+
901
+ // The target may not exist yet (e.g. write_file creating a new file).
902
+ // Walk up to the first existing ancestor, canonicalize that, then re-attach
903
+ // the remaining suffix so we can check containment without requiring the
904
+ // leaf to exist.
905
+ let mut existing = candidate.clone();
906
+ let mut suffix = std::path::PathBuf::new();
907
+ loop {
908
+ if existing.exists() {
909
+ break;
910
+ }
911
+ if let Some(parent) = existing.parent() {
912
+ if let Some(file_name) = existing.file_name() {
913
+ suffix = std::path::Path::new(file_name).join(&suffix);
914
+ existing = parent.to_path_buf();
915
+ } else {
916
+ break;
917
+ }
918
+ } else {
919
+ break;
920
+ }
921
+ }
922
+
923
+ let canon_existing = existing
924
+ .canonicalize()
925
+ .map_err(|e| format!("Path resolution error: {e}"))?;
926
+ let resolved = canon_existing.join(&suffix);
927
+
928
+ if !resolved.starts_with(&sandbox_canon) {
929
+ return Err(format!(
930
+ "Access denied: '{}' is outside the sandbox directory",
931
+ user_path
932
+ ));
933
+ }
934
+ Ok(resolved)
935
+ }
936
+
937
+ async fn run_filesystem_agent_stream(
938
+ &self,
939
+ initial_messages: &[ChatMessage],
940
+ sandbox_path: &Path,
941
+ tx: tokio::sync::mpsc::UnboundedSender<String>,
942
+ ) -> Result<Option<Usage>, String> {
943
+ let (api_key, api_url, model_name) = if let Some(ref g) = self.glm {
944
+ (
945
+ g.api_key.clone(),
946
+ "https://open.bigmodel.cn/api/paas/v4/chat/completions",
947
+ "glm-4-pro",
948
+ )
949
+ } else if let Some(ref ds) = self.deepseek {
950
+ (
951
+ ds.api_key.clone(),
952
+ "https://api.deepseek.com/chat/completions",
953
+ "deepseek-chat",
954
+ )
955
+ } else {
956
+ return Err("No API client initialized for Filesystem Sub-Agent.".to_string());
957
+ };
958
+
959
+ let http_client = reqwest::Client::new();
960
+ let mut messages = initial_messages.to_vec();
961
+
962
+ let sandbox_display = sandbox_path.to_string_lossy();
963
+
964
+ if let Some(first_msg) = messages.get_mut(0) {
965
+ if first_msg.role == "system" {
966
+ first_msg.content = Some(format!(
967
+ "You are the COBOLX Filesystem Sub-Agent. You help users read, analyze, and write files \
968
+ in their COBOL project sandbox.\n\
969
+ \n\
970
+ Sandbox root: {sandbox_display}\n\
971
+ All paths you pass to tools must be relative to the sandbox root (e.g. 'src/MAIN.cbl') \
972
+ or absolute paths that start with the sandbox root. Absolute paths outside the sandbox \
973
+ will be rejected.\n\
974
+ \n\
975
+ Available tools:\n\
976
+ - read_file: read the full text content of a file.\n\
977
+ - write_file: create or overwrite a file with new content.\n\
978
+ - list_directory: list entries inside a directory, optionally filtered by extension.\n\
979
+ - search_in_file: search for a text pattern (case-insensitive) and get matching lines with numbers.\n\
980
+ \n\
981
+ GUIDELINES:\n\
982
+ - Always read a file before writing to it if you need to preserve existing content.\n\
983
+ - When reading large COBOL files, focus on the relevant sections the user asked about.\n\
984
+ - Prefer relative paths for portability.\n\
985
+ - If a file does not exist, say so clearly before attempting to write."
986
+ ));
987
+ }
988
+ }
989
+
990
+ let read_file_tool = Tool {
991
+ r#type: "function".to_string(),
992
+ function: FunctionDefinition {
993
+ name: "read_file".to_string(),
994
+ description: "Read the full text content of a file inside the sandbox.".to_string(),
995
+ parameters: serde_json::json!({
996
+ "type": "object",
997
+ "properties": {
998
+ "path": {
999
+ "type": "string",
1000
+ "description": "Path relative to sandbox root, e.g. 'src/PROGRAM.cbl'"
1001
+ }
1002
+ },
1003
+ "required": ["path"]
1004
+ }),
1005
+ },
1006
+ };
1007
+
1008
+ let write_file_tool = Tool {
1009
+ r#type: "function".to_string(),
1010
+ function: FunctionDefinition {
1011
+ name: "write_file".to_string(),
1012
+ description:
1013
+ "Create or overwrite a file inside the sandbox with the given content."
1014
+ .to_string(),
1015
+ parameters: serde_json::json!({
1016
+ "type": "object",
1017
+ "properties": {
1018
+ "path": {
1019
+ "type": "string",
1020
+ "description": "Path relative to sandbox root, e.g. 'output/Main.java'"
1021
+ },
1022
+ "content": {
1023
+ "type": "string",
1024
+ "description": "The complete text content to write to the file."
1025
+ }
1026
+ },
1027
+ "required": ["path", "content"]
1028
+ }),
1029
+ },
1030
+ };
1031
+
1032
+ let list_directory_tool = Tool {
1033
+ r#type: "function".to_string(),
1034
+ function: FunctionDefinition {
1035
+ name: "list_directory".to_string(),
1036
+ description: "List files and subdirectories inside a sandbox directory."
1037
+ .to_string(),
1038
+ parameters: serde_json::json!({
1039
+ "type": "object",
1040
+ "properties": {
1041
+ "path": {
1042
+ "type": "string",
1043
+ "description": "Directory path relative to sandbox root. Use '.' for the root itself."
1044
+ },
1045
+ "extension": {
1046
+ "type": "string",
1047
+ "description": "Optional extension filter, e.g. '.cbl', '.cpy', '.java'. Omit to list everything."
1048
+ }
1049
+ },
1050
+ "required": ["path"]
1051
+ }),
1052
+ },
1053
+ };
1054
+
1055
+ let search_in_file_tool = Tool {
1056
+ r#type: "function".to_string(),
1057
+ function: FunctionDefinition {
1058
+ name: "search_in_file".to_string(),
1059
+ description: "Search for a text pattern (case-insensitive) in a file. Returns matching lines with their line numbers.".to_string(),
1060
+ parameters: serde_json::json!({
1061
+ "type": "object",
1062
+ "properties": {
1063
+ "path": {
1064
+ "type": "string",
1065
+ "description": "File path relative to sandbox root."
1066
+ },
1067
+ "pattern": {
1068
+ "type": "string",
1069
+ "description": "Text pattern to search for (plain text, case-insensitive)."
1070
+ }
1071
+ },
1072
+ "required": ["path", "pattern"]
1073
+ }),
1074
+ },
1075
+ };
1076
+
1077
+ let tools = vec![
1078
+ read_file_tool,
1079
+ write_file_tool,
1080
+ list_directory_tool,
1081
+ search_in_file_tool,
1082
+ ];
1083
+ let mut final_usage = Usage::default();
1084
+
1085
+ for _turn in 0..8 {
1086
+ let request_body = ChatRequest {
1087
+ model: model_name.to_string(),
1088
+ messages: messages.clone(),
1089
+ stream: true,
1090
+ temperature: Some(0.2),
1091
+ stream_options: Some(StreamOptions {
1092
+ include_usage: true,
1093
+ }),
1094
+ tools: Some(tools.clone()),
1095
+ };
1096
+
1097
+ let response = http_client
1098
+ .post(api_url)
1099
+ .header("Authorization", format!("Bearer {}", api_key))
1100
+ .json(&request_body)
1101
+ .send()
1102
+ .await
1103
+ .map_err(|e| format!("Network error: {e}"))?;
1104
+
1105
+ if !response.status().is_success() {
1106
+ let err_body = response.text().await.unwrap_or_default();
1107
+ return Err(format!("Filesystem Sub-Agent API error: {err_body}"));
1108
+ }
1109
+
1110
+ use futures_util::StreamExt;
1111
+ let mut stream = response.bytes_stream();
1112
+ let mut buffer = String::new();
1113
+ let mut tool_calls_accumulated: Vec<ToolCall> = Vec::new();
1114
+
1115
+ while let Some(chunk_res) = stream.next().await {
1116
+ let chunk = chunk_res.map_err(|e| format!("Stream read error: {e}"))?;
1117
+ let chunk_str = String::from_utf8_lossy(&chunk);
1118
+ buffer.push_str(&chunk_str);
1119
+
1120
+ while let Some(pos) = buffer.find('\n') {
1121
+ let line = buffer[..pos].to_string();
1122
+ buffer.drain(..=pos);
1123
+
1124
+ let trimmed = line.trim();
1125
+ if trimmed.is_empty() || trimmed == "data: [DONE]" {
1126
+ continue;
1127
+ }
1128
+ if let Some(json_str) = trimmed.strip_prefix("data: ") {
1129
+ if let Ok(parsed) = serde_json::from_str::<ChatResponseStream>(json_str) {
1130
+ if let Some(ref usage) = parsed.usage {
1131
+ final_usage.prompt_tokens += usage.prompt_tokens;
1132
+ final_usage.completion_tokens += usage.completion_tokens;
1133
+ final_usage.total_tokens += usage.total_tokens;
1134
+ }
1135
+ if let Some(choice) = parsed.choices.first() {
1136
+ if let Some(ref content) = choice.delta.content {
1137
+ if !content.is_empty() {
1138
+ let _ = tx.send(content.clone());
1139
+ }
1140
+ }
1141
+ if let Some(ref deltas) = choice.delta.tool_calls {
1142
+ merge_tool_call_deltas(
1143
+ &mut tool_calls_accumulated,
1144
+ deltas.clone(),
1145
+ );
1146
+ }
1147
+ }
1148
+ }
1149
+ }
1150
+ }
1151
+ }
1152
+
1153
+ if tool_calls_accumulated.is_empty() {
1154
+ break;
1155
+ }
1156
+
1157
+ let assistant_msg = ChatMessage {
1158
+ role: "assistant".to_string(),
1159
+ content: None,
1160
+ tool_call_id: None,
1161
+ tool_calls: Some(tool_calls_accumulated.clone()),
1162
+ };
1163
+ messages.push(assistant_msg);
1164
+
1165
+ for tc in &tool_calls_accumulated {
1166
+ let args: serde_json::Value = serde_json::from_str(&tc.function.arguments)
1167
+ .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
1168
+
1169
+ let tool_result = match tc.function.name.as_str() {
1170
+ "read_file" => {
1171
+ let path_str = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
1172
+ let _ = tx.send(format!("\x01STATUS:Reading file: {path_str}"));
1173
+ match Self::validate_sandbox_path(sandbox_path, path_str) {
1174
+ Err(e) => serde_json::json!({ "error": e }).to_string(),
1175
+ Ok(full_path) => match std::fs::read_to_string(&full_path) {
1176
+ Err(e) => serde_json::json!({ "error": e.to_string() }).to_string(),
1177
+ Ok(content) => {
1178
+ const MAX_BYTES: usize = 120_000;
1179
+ let truncated = if content.len() > MAX_BYTES {
1180
+ format!(
1181
+ "[File truncated: showing first {} of {} bytes]\n{}",
1182
+ MAX_BYTES,
1183
+ content.len(),
1184
+ &content[..MAX_BYTES]
1185
+ )
1186
+ } else {
1187
+ content
1188
+ };
1189
+ serde_json::json!({
1190
+ "path": full_path.to_string_lossy(),
1191
+ "content": truncated
1192
+ })
1193
+ .to_string()
1194
+ }
1195
+ },
1196
+ }
1197
+ }
1198
+
1199
+ "write_file" => {
1200
+ let path_str = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
1201
+ let content = args.get("content").and_then(|v| v.as_str()).unwrap_or("");
1202
+ let _ = tx.send(format!("\x01STATUS:Writing file: {path_str}"));
1203
+ match Self::validate_sandbox_path(sandbox_path, path_str) {
1204
+ Err(e) => serde_json::json!({ "error": e }).to_string(),
1205
+ Ok(full_path) => {
1206
+ if let Some(parent) = full_path.parent() {
1207
+ if let Err(e) = std::fs::create_dir_all(parent) {
1208
+ return Err(format!("Failed to create directories: {e}"));
1209
+ }
1210
+ }
1211
+ match std::fs::write(&full_path, content) {
1212
+ Ok(_) => serde_json::json!({
1213
+ "ok": true,
1214
+ "path": full_path.to_string_lossy(),
1215
+ "bytes_written": content.len()
1216
+ })
1217
+ .to_string(),
1218
+ Err(e) => {
1219
+ serde_json::json!({ "error": e.to_string() }).to_string()
1220
+ }
1221
+ }
1222
+ }
1223
+ }
1224
+ }
1225
+
1226
+ "list_directory" => {
1227
+ let path_str = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
1228
+ let ext_filter = args.get("extension").and_then(|v| v.as_str());
1229
+ let _ = tx.send(format!("\x01STATUS:Listing directory: {path_str}"));
1230
+ match Self::validate_sandbox_path(sandbox_path, path_str) {
1231
+ Err(e) => serde_json::json!({ "error": e }).to_string(),
1232
+ Ok(full_path) => match std::fs::read_dir(&full_path) {
1233
+ Err(e) => serde_json::json!({ "error": e.to_string() }).to_string(),
1234
+ Ok(entries) => {
1235
+ let sandbox_canon = sandbox_path
1236
+ .canonicalize()
1237
+ .unwrap_or_else(|_| sandbox_path.to_path_buf());
1238
+ let mut files: Vec<serde_json::Value> = entries
1239
+ .filter_map(|e| e.ok())
1240
+ .filter(|e| {
1241
+ if let Some(ext) = ext_filter {
1242
+ e.path()
1243
+ .extension()
1244
+ .and_then(|s| s.to_str())
1245
+ .map(|s| {
1246
+ format!(".{s}").eq_ignore_ascii_case(ext)
1247
+ })
1248
+ .unwrap_or(false)
1249
+ } else {
1250
+ true
1251
+ }
1252
+ })
1253
+ .map(|e| {
1254
+ let p = e.path();
1255
+ let rel = p
1256
+ .strip_prefix(&sandbox_canon)
1257
+ .unwrap_or(&p)
1258
+ .to_string_lossy()
1259
+ .into_owned();
1260
+ let kind = if p.is_dir() { "dir" } else { "file" };
1261
+ serde_json::json!({ "name": rel, "kind": kind })
1262
+ })
1263
+ .collect();
1264
+ files.sort_by_key(|v| {
1265
+ v["name"].as_str().unwrap_or("").to_string()
1266
+ });
1267
+ serde_json::json!({ "entries": files }).to_string()
1268
+ }
1269
+ },
1270
+ }
1271
+ }
1272
+
1273
+ "search_in_file" => {
1274
+ let path_str = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
1275
+ let pattern = args.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
1276
+ let _ = tx.send(format!("\x01STATUS:Searching '{pattern}' in {path_str}"));
1277
+ match Self::validate_sandbox_path(sandbox_path, path_str) {
1278
+ Err(e) => serde_json::json!({ "error": e }).to_string(),
1279
+ Ok(full_path) => match std::fs::read_to_string(&full_path) {
1280
+ Err(e) => serde_json::json!({ "error": e.to_string() }).to_string(),
1281
+ Ok(content) => {
1282
+ let pat_lower = pattern.to_lowercase();
1283
+ let matches: Vec<serde_json::Value> = content
1284
+ .lines()
1285
+ .enumerate()
1286
+ .filter(|(_, line)| {
1287
+ line.to_lowercase().contains(&pat_lower)
1288
+ })
1289
+ .map(|(i, line)| {
1290
+ serde_json::json!({
1291
+ "line": i + 1,
1292
+ "text": line
1293
+ })
1294
+ })
1295
+ .collect();
1296
+ serde_json::json!({
1297
+ "pattern": pattern,
1298
+ "match_count": matches.len(),
1299
+ "matches": matches
1300
+ })
1301
+ .to_string()
1302
+ }
1303
+ },
1304
+ }
1305
+ }
1306
+
1307
+ unknown => serde_json::json!({
1308
+ "error": format!("Unknown tool: {unknown}")
1309
+ })
1310
+ .to_string(),
1311
+ };
1312
+
1313
+ let _ = tx.send("\x01STATUS:".to_string());
1314
+
1315
+ messages.push(ChatMessage {
1316
+ role: "tool".to_string(),
1317
+ content: Some(tool_result),
1318
+ tool_call_id: Some(tc.id.clone()),
1319
+ tool_calls: None,
1320
+ });
1321
+ }
1322
+ }
1323
+
1324
+ let _ = tx.send("\x01STATUS:".to_string());
1325
+ Ok(Some(final_usage))
1326
+ }
1327
+ }
1328
+
1329
+ #[cfg(test)]
1330
+ mod tests {
1331
+ use super::*;
1332
+
1333
+ #[test]
1334
+ fn test_config_generation() {
1335
+ let router = AgentRouter::new();
1336
+ assert!(router.config_path.is_some());
1337
+ let path = router.config_path.clone().unwrap();
1338
+ println!("Generated config path: {}", path);
1339
+ let path_buf = std::path::PathBuf::from(path);
1340
+ assert!(path_buf.exists());
1341
+ let content = std::fs::read_to_string(path_buf).unwrap();
1342
+ assert!(content.contains("deepseek_api_key"));
1343
+ assert!(content.contains("glm_api_key"));
1344
+ }
1345
+ }