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.
- package/.devcontainer/devcontainer.json +26 -0
- package/.dockerignore +4 -0
- package/.github/workflows/ci.yml +157 -0
- package/Cargo.lock +2245 -0
- package/Cargo.toml +39 -0
- package/bin/check-update.js +44 -0
- package/bin/cobolx.js +81 -0
- package/docker-compose.yml +33 -0
- package/dockerfile +18 -0
- package/dockerfile.test +39 -0
- package/package.json +27 -0
- package/scripts/install.js +145 -0
- package/src/agent/client.rs +1345 -0
- package/src/agent.rs +1 -0
- package/src/cobol/copybook.rs +71 -0
- package/src/cobol/data_parser.rs +290 -0
- package/src/cobol/indexer.rs +256 -0
- package/src/cobol/layout.rs +278 -0
- package/src/cobol/lexer.rs +135 -0
- package/src/cobol/model.rs +196 -0
- package/src/cobol/scanner.rs +72 -0
- package/src/cobol/source_parser.rs +91 -0
- package/src/cobol.rs +8 -0
- package/src/config/config.rs +64 -0
- package/src/config.rs +3 -0
- package/src/lib.rs +6 -0
- package/src/main.rs +20 -0
- package/src/memory/files.rs +155 -0
- package/src/memory/store.rs +406 -0
- package/src/memory.rs +5 -0
- package/src/ui/draw.rs +519 -0
- package/src/ui/tui.rs +812 -0
- package/src/ui.rs +2 -0
- package/tests/indexer_tests.rs +192 -0
- package/tests/memory_store_tests.rs +21 -0
- package/tests/project_files_tests.rs +72 -0
- package/tests/sandbox_tests.rs +178 -0
|
@@ -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
|
+
}
|