@pheem49/mint 1.5.5 → 1.6.1
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/.codex +0 -0
- package/.github/FUNDING.yml +2 -0
- package/.github/workflows/ci.yml +45 -0
- package/.github/workflows/release.yml +79 -0
- package/Cargo.lock +5792 -0
- package/Cargo.toml +32 -0
- package/README.md +387 -353
- package/assets/icon.png +0 -0
- package/bin/mint +0 -0
- package/crates/mint-cli/Cargo.toml +23 -0
- package/crates/mint-cli/src/agent.rs +851 -0
- package/crates/mint-cli/src/gmail.rs +216 -0
- package/crates/mint-cli/src/image.rs +142 -0
- package/crates/mint-cli/src/main.rs +2837 -0
- package/crates/mint-cli/src/mcp.rs +63 -0
- package/crates/mint-cli/src/onboard.rs +1149 -0
- package/crates/mint-cli/src/setup.rs +390 -0
- package/crates/mint-cli/src/skills.rs +8 -0
- package/crates/mint-cli/src/updater.rs +279 -0
- package/crates/mint-core/Cargo.toml +22 -0
- package/crates/mint-core/src/agent_loop.rs +94 -0
- package/crates/mint-core/src/api_server.rs +991 -0
- package/crates/mint-core/src/channels.rs +248 -0
- package/crates/mint-core/src/chat.rs +895 -0
- package/crates/mint-core/src/code_tools.rs +729 -0
- package/crates/mint-core/src/config.rs +368 -0
- package/crates/mint-core/src/files.rs +159 -0
- package/crates/mint-core/src/knowledge.rs +541 -0
- package/crates/mint-core/src/lib.rs +84 -0
- package/crates/mint-core/src/mcp.rs +273 -0
- package/crates/mint-core/src/memory.rs +673 -0
- package/crates/mint-core/src/orchestration.rs +2157 -0
- package/crates/mint-core/src/pictures.rs +314 -0
- package/crates/mint-core/src/plugins.rs +727 -0
- package/crates/mint-core/src/safety.rs +416 -0
- package/crates/mint-core/src/semantic.rs +254 -0
- package/crates/mint-core/src/shell.rs +317 -0
- package/crates/mint-core/src/skills.rs +71 -0
- package/crates/mint-core/src/symbols.rs +157 -0
- package/crates/mint-core/src/tasks.rs +308 -0
- package/crates/mint-core/src/tts.rs +92 -0
- package/crates/mint-core/src/weather.rs +93 -0
- package/crates/mint-core/src/web_search.rs +200 -0
- package/crates/mint-core/src/workflows.rs +81 -0
- package/crates/mint-core/tests/mcp_stdio.rs +45 -0
- package/crates/mint-core/tests/memory_persistence.rs +172 -0
- package/crates/mint-core/tests/pictures_storage.rs +14 -0
- package/crates/mint-core/tests/task_lifecycle.rs +87 -0
- package/package.json +35 -99
- package/src/bin/index.js +16 -0
- package/src/renderer/index-web.html +17 -0
- package/src/renderer/index.html +17 -0
- package/src/renderer/public/Live2DCubismCore.js +9 -0
- package/src/renderer/public/assets/icon.png +0 -0
- package/src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.model3.json +36 -0
- package/src/renderer/src/App.tsx +33 -0
- package/src/renderer/src/calculator.ts +47 -0
- package/src/renderer/src/components/ChatPanel.tsx +1598 -0
- package/src/renderer/src/components/DashboardSidebar.tsx +358 -0
- package/src/renderer/src/components/Live2DStage.tsx +374 -0
- package/src/renderer/src/components/MintDashboard.tsx +950 -0
- package/src/renderer/src/components/ModelPanel.tsx +154 -0
- package/src/renderer/src/components/PicturesLibrary.tsx +46 -0
- package/src/renderer/src/components/ProactiveGlow.tsx +19 -0
- package/src/renderer/src/components/ScreenPicker.tsx +579 -0
- package/src/renderer/src/components/SettingsWindow.tsx +1467 -0
- package/src/renderer/src/components/SpotlightWindow.tsx +280 -0
- package/src/renderer/src/components/WidgetWindow.tsx +36 -0
- package/src/renderer/src/components/WorkspacePanel.tsx +268 -0
- package/src/{UI → renderer/src/css}/settings.css +69 -16
- package/src/renderer/src/css/spotlight.css +113 -0
- package/src/renderer/src/css/styles.css +3722 -0
- package/src/renderer/src/css/widget.css +185 -0
- package/src/renderer/src/env.d.ts +116 -0
- package/src/renderer/src/index.css +379 -0
- package/src/renderer/src/main.tsx +13 -0
- package/src/renderer/src/tauri.ts +996 -0
- package/src/renderer/src-web/App.tsx +25 -0
- package/src/renderer/src-web/calculator.ts +47 -0
- package/src/renderer/src-web/components/ChatPanel.tsx +1662 -0
- package/src/renderer/src-web/components/DashboardSidebar.tsx +242 -0
- package/src/renderer/src-web/components/MintDashboard.tsx +763 -0
- package/src/renderer/src-web/components/PicturesLibrary.tsx +73 -0
- package/src/renderer/src-web/components/SettingsWindow.tsx +1500 -0
- package/src/renderer/src-web/css/settings.css +1100 -0
- package/src/{UI → renderer/src-web/css}/spotlight.css +4 -4
- package/src/{UI → renderer/src-web/css}/styles.css +1055 -159
- package/src/{UI → renderer/src-web/css}/widget.css +2 -2
- package/src/renderer/src-web/env.d.ts +107 -0
- package/src/renderer/src-web/index.css +379 -0
- package/src/renderer/src-web/main.tsx +13 -0
- package/src/renderer/src-web/tauri.ts +983 -0
- package/tsconfig.json +30 -0
- package/vite.config.ts +33 -0
- package/vite.config.web.ts +51 -0
- package/GUIDE_TH.md +0 -125
- package/assets/Agent_Mint.png +0 -0
- package/assets/CLI_Screen.png +0 -0
- package/assets/Settings.png +0 -0
- package/benchmark_ai.js +0 -71
- package/install.ps1 +0 -64
- package/install.sh +0 -54
- package/main.js +0 -139
- package/mint-cli-logic.js +0 -3
- package/mint-cli.js +0 -410
- package/models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.model3.json +0 -47
- package/models/Shiroko_Model/Shiroko//342/232/241/351/253/230/344/272/256/342/232/241/344/275/277/347/224/250/346/225/231/347/250/213/344/270/216/346/263/250/346/204/217/344/272/213/351/241/271.txt +0 -23
- package/preload-picker.js +0 -11
- package/preload-settings.js +0 -11
- package/preload.js +0 -41
- package/scripts/install_linux_desktop_entry.js +0 -48
- package/src/AI_Brain/Gemini_API.js +0 -813
- package/src/AI_Brain/agent_orchestrator.js +0 -73
- package/src/AI_Brain/autonomous_brain.js +0 -179
- package/src/AI_Brain/behavior_memory.js +0 -135
- package/src/AI_Brain/headless_agent.js +0 -143
- package/src/AI_Brain/knowledge_base.js +0 -349
- package/src/AI_Brain/memory_store.js +0 -662
- package/src/AI_Brain/proactive_engine.js +0 -172
- package/src/AI_Brain/provider_adapter.js +0 -365
- package/src/Automation_Layer/browser_automation.js +0 -149
- package/src/Automation_Layer/file_operations.js +0 -286
- package/src/Automation_Layer/open_app.js +0 -85
- package/src/Automation_Layer/open_website.js +0 -38
- package/src/CLI/approval_handler.js +0 -47
- package/src/CLI/chat_router.js +0 -247
- package/src/CLI/chat_ui.js +0 -1159
- package/src/CLI/cli_colors.js +0 -115
- package/src/CLI/cli_formatters.js +0 -94
- package/src/CLI/code_agent.js +0 -1667
- package/src/CLI/code_session_memory.js +0 -62
- package/src/CLI/gmail_auth.js +0 -210
- package/src/CLI/image_input.js +0 -90
- package/src/CLI/intent_detectors.js +0 -181
- package/src/CLI/interactive_chat.js +0 -658
- package/src/CLI/list_features.js +0 -64
- package/src/CLI/onboarding.js +0 -416
- package/src/CLI/repo_summarizer.js +0 -282
- package/src/CLI/semantic_code_search.js +0 -312
- package/src/CLI/skill_manager.js +0 -41
- package/src/CLI/slash_command_handler.js +0 -418
- package/src/CLI/symbol_indexer.js +0 -231
- package/src/CLI/updater.js +0 -230
- package/src/CLI/workspace_manager.js +0 -90
- package/src/Channels/brave_search_bridge.js +0 -35
- package/src/Channels/discord_bridge.js +0 -66
- package/src/Channels/google_search_bridge.js +0 -38
- package/src/Channels/line_bridge.js +0 -60
- package/src/Channels/slack_bridge.js +0 -48
- package/src/Channels/telegram_bridge.js +0 -41
- package/src/Channels/whatsapp_bridge.js +0 -57
- package/src/Command_Parser/parser.js +0 -45
- package/src/Plugins/dev_tools.js +0 -41
- package/src/Plugins/discord.js +0 -20
- package/src/Plugins/docker.js +0 -47
- package/src/Plugins/gmail.js +0 -251
- package/src/Plugins/google_calendar.js +0 -252
- package/src/Plugins/mcp_manager.js +0 -95
- package/src/Plugins/notion.js +0 -256
- package/src/Plugins/obsidian.js +0 -54
- package/src/Plugins/plugin_manager.js +0 -81
- package/src/Plugins/spotify.js +0 -173
- package/src/Plugins/system_metrics.js +0 -31
- package/src/Plugins/system_monitor.js +0 -72
- package/src/System/action_executor.js +0 -178
- package/src/System/bridge_manager.js +0 -76
- package/src/System/chat_history_manager.js +0 -83
- package/src/System/config_manager.js +0 -194
- package/src/System/custom_workflows.js +0 -163
- package/src/System/daemon_manager.js +0 -67
- package/src/System/google_tts_urls.js +0 -51
- package/src/System/granular_automation.js +0 -157
- package/src/System/ipc_handlers.js +0 -332
- package/src/System/notifications.js +0 -23
- package/src/System/optional_require.js +0 -23
- package/src/System/picture_store.js +0 -109
- package/src/System/proactive_loop.js +0 -153
- package/src/System/safety_manager.js +0 -273
- package/src/System/sandbox_runner.js +0 -182
- package/src/System/screen_capture.js +0 -175
- package/src/System/smart_context.js +0 -227
- package/src/System/system_automation.js +0 -162
- package/src/System/system_events.js +0 -79
- package/src/System/system_info.js +0 -125
- package/src/System/task_manager.js +0 -222
- package/src/System/tool_registry.js +0 -293
- package/src/System/window_manager.js +0 -220
- package/src/UI/floating.css +0 -80
- package/src/UI/floating.html +0 -17
- package/src/UI/floating.js +0 -67
- package/src/UI/live2d_manager.js +0 -600
- package/src/UI/preload-floating.js +0 -7
- package/src/UI/preload-spotlight.js +0 -11
- package/src/UI/preload-widget.js +0 -5
- package/src/UI/proactive-glow.html +0 -42
- package/src/UI/renderer.js +0 -2127
- package/src/UI/screenPicker.html +0 -214
- package/src/UI/screenPicker.js +0 -262
- package/src/UI/settings.html +0 -577
- package/src/UI/settings.js +0 -770
- package/src/UI/spotlight.html +0 -23
- package/src/UI/spotlight.js +0 -185
- package/src/UI/widget.html +0 -29
- package/src/UI/widget.js +0 -10
- /package/{models → src/renderer/public/models}/Shiroko_Model/Shiroko/Shiroko_Core/72d86db84cfa9730b894c241fd24c0db.png +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//345/233/264/350/243/231.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/apron.exp3.json} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//347/214/253/345/222/252/346/273/244/351/225/234.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/catfilter.exp3.json} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//347/202/271/344/270/200/344/270/213.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/click.exp3.json} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//345/221/206/347/214/253.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/dazed.exp3.json} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//345/221/206/347/214/253/347/234/274/347/217/240/346/221/207/346/231/203.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/dazedeyes.exp3.json} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//347/234/274/351/225/234.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/glasses.exp3.json} +0 -0
- /package/{models → src/renderer/public/models}/Shiroko_Model/Shiroko/Shiroko_Core/items_pinned_to_model.json +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//346/213/277/347/254/224.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/pen.exp3.json} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//346/213/215/347/205/247.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/photo.exp3.json} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_00.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_00.png} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_01.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_01.png} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_02.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_02.png} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_03.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_03.png} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.cdi3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.cdi3.json} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.moc3" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.moc3} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.physics3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.physics3.json} +0 -0
- /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.vtube.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.vtube.json} +0 -0
|
@@ -0,0 +1,2157 @@
|
|
|
1
|
+
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
|
2
|
+
use serde::{Deserialize, Serialize};
|
|
3
|
+
use serde_json::Value;
|
|
4
|
+
use std::collections::BTreeMap;
|
|
5
|
+
use std::path::{Path, PathBuf};
|
|
6
|
+
use std::process::Command;
|
|
7
|
+
use std::time::Instant;
|
|
8
|
+
use thiserror::Error;
|
|
9
|
+
|
|
10
|
+
use crate::chat::{send_chat_with_fallback, stream_chat_with_fallback};
|
|
11
|
+
use crate::code_tools::{
|
|
12
|
+
CodeEdit, CodePatchHunk, apply_code_edits, build_code_patch, list_code_files,
|
|
13
|
+
propose_code_edits, read_code_file, search_code,
|
|
14
|
+
};
|
|
15
|
+
use crate::knowledge::KnowledgeStore;
|
|
16
|
+
use crate::plugins::execute_native_plugin;
|
|
17
|
+
use crate::semantic::{index_semantic_code, search_semantic_code};
|
|
18
|
+
use crate::shell::run_shell_command;
|
|
19
|
+
use crate::symbols::build_symbol_index;
|
|
20
|
+
use crate::{
|
|
21
|
+
Capability, ChatError, ChatRequest, ChatResponse, DEFAULT_CONVERSATION_ID, MemoryError,
|
|
22
|
+
MemoryStore, MintConfig, assert_path_capability, classify_shell_command, send_chat,
|
|
23
|
+
stream_chat,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const CONTEXT_LIMIT: usize = 6;
|
|
27
|
+
|
|
28
|
+
#[derive(Debug, Error)]
|
|
29
|
+
pub enum OrchestrationError {
|
|
30
|
+
#[error(transparent)]
|
|
31
|
+
Chat(#[from] ChatError),
|
|
32
|
+
#[error(transparent)]
|
|
33
|
+
Memory(#[from] MemoryError),
|
|
34
|
+
#[error("agent error: {0}")]
|
|
35
|
+
Agent(String),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
pub async fn resolve_github_links(message: &str, config: &MintConfig) -> String {
|
|
39
|
+
// Check if a GitHub MCP server is configured in Settings
|
|
40
|
+
let github_mcp_configured = crate::mcp::configured_mcp_servers(config)
|
|
41
|
+
.ok()
|
|
42
|
+
.map(|servers| servers.contains_key("github"))
|
|
43
|
+
.unwrap_or(false);
|
|
44
|
+
|
|
45
|
+
if github_mcp_configured {
|
|
46
|
+
// If GitHub MCP is active, we let it handle the repo via tool calls
|
|
47
|
+
// to avoid duplicate/redundant context.
|
|
48
|
+
return message.to_string();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
use std::sync::OnceLock;
|
|
52
|
+
static RE: OnceLock<regex::Regex> = OnceLock::new();
|
|
53
|
+
let re = RE.get_or_init(|| {
|
|
54
|
+
regex::Regex::new(r"https?://(?:www\.)?github\.com/([a-zA-Z0-9\-_.]+)/([a-zA-Z0-9\-_.]+)")
|
|
55
|
+
.unwrap()
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
let mut resolved_msg = message.to_string();
|
|
59
|
+
let mut resolved_repos = std::collections::HashSet::new();
|
|
60
|
+
|
|
61
|
+
for caps in re.captures_iter(message) {
|
|
62
|
+
if let (Some(owner_match), Some(repo_match)) = (caps.get(1), caps.get(2)) {
|
|
63
|
+
let owner = owner_match.as_str();
|
|
64
|
+
let mut repo = repo_match.as_str().to_string();
|
|
65
|
+
if repo.ends_with(".git") {
|
|
66
|
+
repo = repo[..repo.len() - 4].to_string();
|
|
67
|
+
}
|
|
68
|
+
let repo_clean: String = repo
|
|
69
|
+
.chars()
|
|
70
|
+
.take_while(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
|
|
71
|
+
.collect();
|
|
72
|
+
|
|
73
|
+
let repo_key = format!("{owner}/{repo_clean}");
|
|
74
|
+
if resolved_repos.insert(repo_key.clone()) {
|
|
75
|
+
if let Ok(summary) =
|
|
76
|
+
crate::code_tools::fetch_github_repo_summary(owner, &repo_clean).await
|
|
77
|
+
{
|
|
78
|
+
resolved_msg.push_str(&format!(
|
|
79
|
+
"\n\n--- Auto-Resolved GitHub Metadata for {} ---\n{}\n--------------------------------------------",
|
|
80
|
+
repo_key, summary
|
|
81
|
+
));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
resolved_msg
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
pub async fn orchestrate_chat(
|
|
90
|
+
config: &MintConfig,
|
|
91
|
+
request: &ChatRequest,
|
|
92
|
+
) -> Result<ChatResponse, OrchestrationError> {
|
|
93
|
+
let mut resolved_request = request.clone();
|
|
94
|
+
resolved_request.message = resolve_github_links(&request.message, config).await;
|
|
95
|
+
let memory = MemoryStore::open_default()?;
|
|
96
|
+
let enriched = enrich_request(&memory, &resolved_request)?;
|
|
97
|
+
let response = send_chat(config, &enriched).await?;
|
|
98
|
+
memory.add_interaction_for_chat_with_fallback(
|
|
99
|
+
request_chat_id(request),
|
|
100
|
+
&request.message,
|
|
101
|
+
&response.text,
|
|
102
|
+
&response.provider,
|
|
103
|
+
&response.model,
|
|
104
|
+
response.fallback_provider.as_deref(),
|
|
105
|
+
)?;
|
|
106
|
+
spawn_auto_memory_update(
|
|
107
|
+
config.clone(),
|
|
108
|
+
request.message.clone(),
|
|
109
|
+
response.text.clone(),
|
|
110
|
+
);
|
|
111
|
+
Ok(response)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
pub async fn orchestrate_chat_stream<F>(
|
|
115
|
+
config: &MintConfig,
|
|
116
|
+
request: &ChatRequest,
|
|
117
|
+
on_chunk: F,
|
|
118
|
+
) -> Result<ChatResponse, OrchestrationError>
|
|
119
|
+
where
|
|
120
|
+
F: FnMut(String),
|
|
121
|
+
{
|
|
122
|
+
let mut resolved_request = request.clone();
|
|
123
|
+
resolved_request.message = resolve_github_links(&request.message, config).await;
|
|
124
|
+
let memory = MemoryStore::open_default()?;
|
|
125
|
+
let enriched = enrich_request(&memory, &resolved_request)?;
|
|
126
|
+
let response = stream_chat(config, &enriched, on_chunk).await?;
|
|
127
|
+
memory.add_interaction_for_chat_with_fallback(
|
|
128
|
+
request_chat_id(request),
|
|
129
|
+
&request.message,
|
|
130
|
+
&response.text,
|
|
131
|
+
&response.provider,
|
|
132
|
+
&response.model,
|
|
133
|
+
response.fallback_provider.as_deref(),
|
|
134
|
+
)?;
|
|
135
|
+
spawn_auto_memory_update(
|
|
136
|
+
config.clone(),
|
|
137
|
+
request.message.clone(),
|
|
138
|
+
response.text.clone(),
|
|
139
|
+
);
|
|
140
|
+
Ok(response)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
pub async fn orchestrate_chat_with_fallback(
|
|
144
|
+
config: &MintConfig,
|
|
145
|
+
request: &ChatRequest,
|
|
146
|
+
) -> Result<(ChatResponse, Option<String>), OrchestrationError> {
|
|
147
|
+
let mut resolved_request = request.clone();
|
|
148
|
+
resolved_request.message = resolve_github_links(&request.message, config).await;
|
|
149
|
+
let memory = MemoryStore::open_default()?;
|
|
150
|
+
let enriched = enrich_request(&memory, &resolved_request)?;
|
|
151
|
+
let (response, fallback) = send_chat_with_fallback(config, &enriched).await?;
|
|
152
|
+
memory.add_interaction_for_chat_with_fallback(
|
|
153
|
+
request_chat_id(request),
|
|
154
|
+
&request.message,
|
|
155
|
+
&response.text,
|
|
156
|
+
&response.provider,
|
|
157
|
+
&response.model,
|
|
158
|
+
response.fallback_provider.as_deref(),
|
|
159
|
+
)?;
|
|
160
|
+
spawn_auto_memory_update(
|
|
161
|
+
config.clone(),
|
|
162
|
+
request.message.clone(),
|
|
163
|
+
response.text.clone(),
|
|
164
|
+
);
|
|
165
|
+
Ok((response, fallback))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
pub async fn orchestrate_chat_stream_with_fallback<F>(
|
|
169
|
+
config: &MintConfig,
|
|
170
|
+
request: &ChatRequest,
|
|
171
|
+
on_chunk: F,
|
|
172
|
+
) -> Result<(ChatResponse, Option<String>), OrchestrationError>
|
|
173
|
+
where
|
|
174
|
+
F: FnMut(String),
|
|
175
|
+
{
|
|
176
|
+
let mut resolved_request = request.clone();
|
|
177
|
+
resolved_request.message = resolve_github_links(&request.message, config).await;
|
|
178
|
+
let memory = MemoryStore::open_default()?;
|
|
179
|
+
let enriched = enrich_request(&memory, &resolved_request)?;
|
|
180
|
+
let (response, fallback) = stream_chat_with_fallback(config, &enriched, on_chunk).await?;
|
|
181
|
+
memory.add_interaction_for_chat_with_fallback(
|
|
182
|
+
request_chat_id(request),
|
|
183
|
+
&request.message,
|
|
184
|
+
&response.text,
|
|
185
|
+
&response.provider,
|
|
186
|
+
&response.model,
|
|
187
|
+
response.fallback_provider.as_deref(),
|
|
188
|
+
)?;
|
|
189
|
+
spawn_auto_memory_update(
|
|
190
|
+
config.clone(),
|
|
191
|
+
request.message.clone(),
|
|
192
|
+
response.text.clone(),
|
|
193
|
+
);
|
|
194
|
+
Ok((response, fallback))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
fn enrich_request(memory: &MemoryStore, request: &ChatRequest) -> Result<ChatRequest, MemoryError> {
|
|
198
|
+
let mut interactions =
|
|
199
|
+
memory.recent_interactions_for_chat(request_chat_id(request), CONTEXT_LIMIT)?;
|
|
200
|
+
interactions.reverse();
|
|
201
|
+
let transcript = interactions
|
|
202
|
+
.into_iter()
|
|
203
|
+
.map(|item| format!("User: {}\nAssistant: {}", item.user_text, item.ai_text))
|
|
204
|
+
.collect::<Vec<_>>()
|
|
205
|
+
.join("\n\n");
|
|
206
|
+
let mut enriched = request.clone();
|
|
207
|
+
|
|
208
|
+
let mut profile_instructions = String::new();
|
|
209
|
+
if let Ok(Some(name)) = memory.get_profile("name") {
|
|
210
|
+
if !name.trim().is_empty() {
|
|
211
|
+
profile_instructions.push_str(&format!("User Name: {}\n", name.trim()));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if let Ok(Some(preferences)) = memory.get_profile("preferences") {
|
|
215
|
+
if !preferences.trim().is_empty() {
|
|
216
|
+
profile_instructions.push_str(&format!(
|
|
217
|
+
"User Preferences & Profile:\n{}\n",
|
|
218
|
+
preferences.trim()
|
|
219
|
+
));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if !profile_instructions.is_empty() {
|
|
224
|
+
enriched.system_instruction = format!(
|
|
225
|
+
"{}\n\nUser Profile Information:\n{}",
|
|
226
|
+
enriched.system_instruction.trim(),
|
|
227
|
+
profile_instructions.trim()
|
|
228
|
+
)
|
|
229
|
+
.trim()
|
|
230
|
+
.to_owned();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if !transcript.is_empty() {
|
|
234
|
+
enriched.system_instruction = format!(
|
|
235
|
+
"{}\n\nRecent conversation context:\n{}",
|
|
236
|
+
enriched.system_instruction.trim(),
|
|
237
|
+
transcript
|
|
238
|
+
)
|
|
239
|
+
.trim()
|
|
240
|
+
.to_owned();
|
|
241
|
+
}
|
|
242
|
+
Ok(enriched)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
fn request_chat_id(request: &ChatRequest) -> &str {
|
|
246
|
+
request
|
|
247
|
+
.chat_id
|
|
248
|
+
.as_deref()
|
|
249
|
+
.map(str::trim)
|
|
250
|
+
.filter(|chat_id| !chat_id.is_empty())
|
|
251
|
+
.unwrap_or(DEFAULT_CONVERSATION_ID)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const MAX_STEPS: usize = 32;
|
|
255
|
+
const MAX_OBSERVATION_BYTES: usize = 16_000;
|
|
256
|
+
pub fn build_system_prompt(config: &MintConfig) -> String {
|
|
257
|
+
let mut allowed_actions = vec![
|
|
258
|
+
"list_files",
|
|
259
|
+
"read_file",
|
|
260
|
+
"search_code",
|
|
261
|
+
"symbols",
|
|
262
|
+
"semantic_index",
|
|
263
|
+
"semantic_search",
|
|
264
|
+
"knowledge_search",
|
|
265
|
+
"web_search",
|
|
266
|
+
"memory_recall",
|
|
267
|
+
"git_status",
|
|
268
|
+
"git_diff",
|
|
269
|
+
"git_log",
|
|
270
|
+
"git_branch",
|
|
271
|
+
"create_plan",
|
|
272
|
+
"update_plan",
|
|
273
|
+
"request_user_approval",
|
|
274
|
+
"ask_user",
|
|
275
|
+
"detect_project",
|
|
276
|
+
"list_tests",
|
|
277
|
+
"read_diagnostics",
|
|
278
|
+
"view_image",
|
|
279
|
+
"note_write",
|
|
280
|
+
"run_plugin",
|
|
281
|
+
"mcp_tool",
|
|
282
|
+
"run_shell",
|
|
283
|
+
"verify",
|
|
284
|
+
"apply_patch",
|
|
285
|
+
"write_file",
|
|
286
|
+
];
|
|
287
|
+
allowed_actions.retain(|action| !config.disabled_tools.contains(&action.to_string()));
|
|
288
|
+
allowed_actions.push("finish");
|
|
289
|
+
|
|
290
|
+
let actions_str = allowed_actions.join("|");
|
|
291
|
+
|
|
292
|
+
let mut input_formats = Vec::new();
|
|
293
|
+
if allowed_actions.contains(&"list_files") {
|
|
294
|
+
input_formats.push(
|
|
295
|
+
"- list_files: {\"path\":\".\",\"limit\":100} (workspace path, ~/path, or allowed user folder like Downloads)",
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
if allowed_actions.contains(&"read_file") {
|
|
299
|
+
input_formats
|
|
300
|
+
.push("- read_file: {\"path\":\"relative/path\",\"startLine\":1,\"endLine\":240}");
|
|
301
|
+
}
|
|
302
|
+
if allowed_actions.contains(&"search_code") {
|
|
303
|
+
input_formats.push("- search_code: {\"query\":\"text\",\"path\":\".\",\"limit\":20}");
|
|
304
|
+
}
|
|
305
|
+
if allowed_actions.contains(&"symbols") {
|
|
306
|
+
input_formats.push("- symbols: {\"path\":\".\",\"limit\":100}");
|
|
307
|
+
}
|
|
308
|
+
if allowed_actions.contains(&"semantic_index") {
|
|
309
|
+
input_formats.push("- semantic_index: {\"path\":\".\"}");
|
|
310
|
+
}
|
|
311
|
+
if allowed_actions.contains(&"semantic_search") {
|
|
312
|
+
input_formats.push(
|
|
313
|
+
"- semantic_search: {\"query\":\"behavior description\",\"path\":\".\",\"limit\":5}",
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
if allowed_actions.contains(&"knowledge_search") {
|
|
317
|
+
input_formats.push("- knowledge_search: {\"query\":\"local knowledge query\",\"limit\":5}");
|
|
318
|
+
}
|
|
319
|
+
if allowed_actions.contains(&"web_search") {
|
|
320
|
+
input_formats.push("- web_search: {\"query\":\"search terms\",\"limit\":5}");
|
|
321
|
+
}
|
|
322
|
+
if allowed_actions.contains(&"memory_recall") {
|
|
323
|
+
input_formats.push("- memory_recall: {\"query\":\"what did user say about X\"}");
|
|
324
|
+
}
|
|
325
|
+
if allowed_actions.contains(&"git_status") {
|
|
326
|
+
input_formats.push("- git_status: {}");
|
|
327
|
+
}
|
|
328
|
+
if allowed_actions.contains(&"git_diff") {
|
|
329
|
+
input_formats.push("- git_diff: {\"path\":\"optional/relative/path\"}");
|
|
330
|
+
}
|
|
331
|
+
if allowed_actions.contains(&"git_log") {
|
|
332
|
+
input_formats.push("- git_log: {\"limit\":5}");
|
|
333
|
+
}
|
|
334
|
+
if allowed_actions.contains(&"git_branch") {
|
|
335
|
+
input_formats.push("- git_branch: {}");
|
|
336
|
+
}
|
|
337
|
+
if allowed_actions.contains(&"create_plan") {
|
|
338
|
+
input_formats
|
|
339
|
+
.push("- create_plan: {\"summary\":\"objective\",\"steps\":[\"step 1\",\"step 2\"]}");
|
|
340
|
+
}
|
|
341
|
+
if allowed_actions.contains(&"update_plan") {
|
|
342
|
+
input_formats.push("- update_plan: {\"steps\":[\"done: step 1\",\"in_progress: step 2\"]}");
|
|
343
|
+
}
|
|
344
|
+
if allowed_actions.contains(&"request_user_approval") {
|
|
345
|
+
input_formats.push("- request_user_approval: {\"title\":\"short title\",\"summary\":\"what needs approval\"}");
|
|
346
|
+
}
|
|
347
|
+
if allowed_actions.contains(&"ask_user") {
|
|
348
|
+
input_formats.push("- ask_user: {\"query\":\"short question\"}");
|
|
349
|
+
}
|
|
350
|
+
if allowed_actions.contains(&"detect_project") {
|
|
351
|
+
input_formats.push("- detect_project: {\"path\":\".\"}");
|
|
352
|
+
}
|
|
353
|
+
if allowed_actions.contains(&"list_tests") {
|
|
354
|
+
input_formats.push("- list_tests: {\"path\":\".\"}");
|
|
355
|
+
}
|
|
356
|
+
if allowed_actions.contains(&"read_diagnostics") {
|
|
357
|
+
input_formats.push("- read_diagnostics: {\"path\":\".\"}");
|
|
358
|
+
}
|
|
359
|
+
if allowed_actions.contains(&"view_image") {
|
|
360
|
+
input_formats.push("- view_image: {\"path\":\"relative/image.png\"}");
|
|
361
|
+
}
|
|
362
|
+
if allowed_actions.contains(&"note_write") {
|
|
363
|
+
input_formats
|
|
364
|
+
.push("- note_write: {\"path\":\"filename.md\",\"fileContent\":\"note content\"}");
|
|
365
|
+
}
|
|
366
|
+
if allowed_actions.contains(&"run_plugin") {
|
|
367
|
+
input_formats.push("- run_plugin: {\"name\":\"gmail|google_calendar|notion|docker|spotify|obsidian|system_metrics\",\"instruction\":\"instruction string\"}");
|
|
368
|
+
}
|
|
369
|
+
if allowed_actions.contains(&"mcp_tool") {
|
|
370
|
+
input_formats.push("- mcp_tool: {\"server\":\"configured-server\",\"tool\":\"tool-name\",\"arguments\":{}}");
|
|
371
|
+
}
|
|
372
|
+
if allowed_actions.contains(&"run_shell") {
|
|
373
|
+
input_formats.push(
|
|
374
|
+
"- run_shell: {\"command\":\"read-only or test command allowed by shell policy\"}",
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
if allowed_actions.contains(&"verify") {
|
|
378
|
+
input_formats.push("- verify: {\"commands\":[\"cargo test\",\"npm test\"]}");
|
|
379
|
+
}
|
|
380
|
+
if allowed_actions.contains(&"apply_patch") {
|
|
381
|
+
input_formats.push("- apply_patch: {\"patch\":{\"path\":\"relative/path\",\"hunks\":[{\"oldText\":\"exact text\",\"newText\":\"replacement\"}]}}");
|
|
382
|
+
}
|
|
383
|
+
if allowed_actions.contains(&"write_file") {
|
|
384
|
+
input_formats.push(
|
|
385
|
+
"- write_file: {\"path\":\"new/relative/path\",\"fileContent\":\"full file content\"}",
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
input_formats.push("- finish: {\"summary\":\"concise final answer\",\"verification\":\"checks run or not run\"}");
|
|
389
|
+
|
|
390
|
+
let input_formats_str = input_formats.join("\n");
|
|
391
|
+
|
|
392
|
+
let mut rules = Vec::new();
|
|
393
|
+
rules.push(
|
|
394
|
+
"0. For casual conversation or questions that need no local tool, use finish immediately.",
|
|
395
|
+
);
|
|
396
|
+
if allowed_actions.contains(&"list_files") || allowed_actions.contains(&"read_file") {
|
|
397
|
+
rules.push("1. Inspect the workspace before editing.");
|
|
398
|
+
rules.push("1a. For user folders such as Downloads, Documents, Desktop, Pictures, Music, or Videos, use list_files with that folder name or ~/folder before using shell.");
|
|
399
|
+
}
|
|
400
|
+
if allowed_actions.contains(&"search_code") {
|
|
401
|
+
rules.push(
|
|
402
|
+
"2. Use search_code before reading many files when searching for a symbol or behavior.",
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
if allowed_actions.contains(&"apply_patch") && allowed_actions.contains(&"write_file") {
|
|
406
|
+
rules.push("3. Use apply_patch for all edits to existing files. write_file is only for creating new files inside the workspace.");
|
|
407
|
+
}
|
|
408
|
+
if allowed_actions.contains(&"run_shell")
|
|
409
|
+
|| allowed_actions.contains(&"write_file")
|
|
410
|
+
|| allowed_actions.contains(&"apply_patch")
|
|
411
|
+
{
|
|
412
|
+
rules.push("4. Shell commands and file edits require user approval. Mint handles approval after you request the tool.");
|
|
413
|
+
}
|
|
414
|
+
if allowed_actions.contains(&"run_shell") {
|
|
415
|
+
rules.push("5. Shell commands are classified as readOnly, test, network, or mutating. Only policy-allowed modes may run after approval. Never request destructive commands such as rm -rf, git reset --hard, git checkout --, or git clean -f.");
|
|
416
|
+
}
|
|
417
|
+
if allowed_actions.contains(&"verify") {
|
|
418
|
+
rules.push("6. Verify code changes when possible. If compile or test commands fail (exit status is not 0), analyze the stdout/stderr to locate the bug, edit the code to fix it, and verify again. Do not stop or give up until the errors are resolved.");
|
|
419
|
+
}
|
|
420
|
+
if allowed_actions.contains(&"web_search") {
|
|
421
|
+
rules.push("7. Use web_search when the user asks to look something up online or needs current information.");
|
|
422
|
+
}
|
|
423
|
+
if allowed_actions.contains(&"memory_recall") {
|
|
424
|
+
rules.push("8. Use memory_recall to search past interactions before asking the user to repeat context.");
|
|
425
|
+
}
|
|
426
|
+
if allowed_actions.contains(&"git_status") {
|
|
427
|
+
rules.push("8a. Use git_status and git_diff before summarizing local code changes.");
|
|
428
|
+
}
|
|
429
|
+
if allowed_actions.contains(&"create_plan") || allowed_actions.contains(&"update_plan") {
|
|
430
|
+
rules.push("8b. Use create_plan/update_plan for multi-step implementation work.");
|
|
431
|
+
}
|
|
432
|
+
if allowed_actions.contains(&"note_write") {
|
|
433
|
+
rules.push("9. Use note_write to save information to ~/.config/mint/notes/ when asked to remember something.");
|
|
434
|
+
}
|
|
435
|
+
if allowed_actions.contains(&"run_plugin") {
|
|
436
|
+
rules.push("10. Use run_plugin only when the requested native plugin is explicitly allowed by policy.");
|
|
437
|
+
}
|
|
438
|
+
rules.push("11. Keep thought short and concrete. Write the thought field in English at all times. Use Thai for the final summary when the task is written in Thai.");
|
|
439
|
+
rules.push("12. Commands that open URLs, files, folders, or launch apps (e.g. xdg-open, open) run in the background. Once they succeed (exit: 0), you are done. Use the 'finish' action immediately.");
|
|
440
|
+
rules.push("13. You may discuss mature personal topics at a non-explicit level, including adult relationships, sex education, intimacy, emotions, and feelings. Keep the tone respectful and supportive, avoid graphic sexual detail, and do not engage with sexual content involving minors, coercion, exploitation, or sexual violence.");
|
|
441
|
+
|
|
442
|
+
let rules_str = rules.join("\n");
|
|
443
|
+
|
|
444
|
+
format!(
|
|
445
|
+
"You are Mint Unified CLI Agent, a pragmatic autonomous assistant working in a local workspace.\n\
|
|
446
|
+
You are also Mint: a cute, warm, and helpful Thai assistant. Speak politely, naturally, and sweetly in Thai when the user writes in Thai. Refer to yourself as \"มิ้น\" and use polite particles such as \"ค่ะ\" and \"นะคะ\" where appropriate. Keep the personality subtle during technical work: be friendly without adding fluff or reducing precision. Write the \"thought\" field in English at all times (never use Thai for the thought field).\n\
|
|
447
|
+
Follow an inspect -> act -> verify loop. Return exactly one JSON object per response, with no markdown:\n\
|
|
448
|
+
{{\"thought\":\"short user-visible progress note\",\"action\":\"{}\",\"input\":{{...}}}}\n\n\
|
|
449
|
+
Input formats:\n\
|
|
450
|
+
{}\n\n\
|
|
451
|
+
Rules:\n\
|
|
452
|
+
{}",
|
|
453
|
+
actions_str, input_formats_str, rules_str
|
|
454
|
+
)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
458
|
+
pub enum AgentApproval {
|
|
459
|
+
WriteFile {
|
|
460
|
+
path: String,
|
|
461
|
+
content: String,
|
|
462
|
+
diff: String,
|
|
463
|
+
},
|
|
464
|
+
ApplyPatch {
|
|
465
|
+
path: String,
|
|
466
|
+
hunks: Vec<CodePatchHunk>,
|
|
467
|
+
diff: String,
|
|
468
|
+
},
|
|
469
|
+
RunShell {
|
|
470
|
+
command: String,
|
|
471
|
+
mode: String,
|
|
472
|
+
},
|
|
473
|
+
NoteWrite {
|
|
474
|
+
path: String,
|
|
475
|
+
content: String,
|
|
476
|
+
},
|
|
477
|
+
RunPlugin {
|
|
478
|
+
name: String,
|
|
479
|
+
instruction: String,
|
|
480
|
+
},
|
|
481
|
+
McpTool {
|
|
482
|
+
server: String,
|
|
483
|
+
tool: String,
|
|
484
|
+
arguments: Value,
|
|
485
|
+
},
|
|
486
|
+
UserApproval {
|
|
487
|
+
title: String,
|
|
488
|
+
prompt: String,
|
|
489
|
+
},
|
|
490
|
+
AskUser {
|
|
491
|
+
question: String,
|
|
492
|
+
},
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
496
|
+
pub enum ApprovalOutcome {
|
|
497
|
+
Approved,
|
|
498
|
+
Denied,
|
|
499
|
+
Intercepted(String),
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
503
|
+
#[serde(tag = "type", content = "data")]
|
|
504
|
+
pub enum AgentProgress {
|
|
505
|
+
Thinking {
|
|
506
|
+
elapsed_secs: u64,
|
|
507
|
+
},
|
|
508
|
+
Thought {
|
|
509
|
+
thought: String,
|
|
510
|
+
},
|
|
511
|
+
ToolStart {
|
|
512
|
+
action: String,
|
|
513
|
+
input: Value,
|
|
514
|
+
},
|
|
515
|
+
ToolEnd {
|
|
516
|
+
action: String,
|
|
517
|
+
input: Value,
|
|
518
|
+
result: String,
|
|
519
|
+
},
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
523
|
+
#[serde(rename_all = "camelCase")]
|
|
524
|
+
pub struct AgentResult {
|
|
525
|
+
pub provider: String,
|
|
526
|
+
pub model: String,
|
|
527
|
+
pub summary: String,
|
|
528
|
+
pub verification: String,
|
|
529
|
+
pub fallback: Option<String>,
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
#[derive(Debug, Serialize)]
|
|
533
|
+
#[serde(rename_all = "camelCase")]
|
|
534
|
+
struct AgentDirectoryEntry {
|
|
535
|
+
name: String,
|
|
536
|
+
path: PathBuf,
|
|
537
|
+
kind: &'static str,
|
|
538
|
+
size: Option<u64>,
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
#[derive(Debug, Deserialize)]
|
|
542
|
+
struct AgentDecision {
|
|
543
|
+
#[serde(default)]
|
|
544
|
+
thought: String,
|
|
545
|
+
action: String,
|
|
546
|
+
#[serde(default, deserialize_with = "deserialize_agent_input")]
|
|
547
|
+
input: AgentInput,
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
#[derive(Debug, Default, Deserialize, Serialize)]
|
|
551
|
+
#[serde(rename_all = "camelCase")]
|
|
552
|
+
struct AgentInput {
|
|
553
|
+
#[serde(default)]
|
|
554
|
+
path: String,
|
|
555
|
+
#[serde(default)]
|
|
556
|
+
query: String,
|
|
557
|
+
#[serde(default)]
|
|
558
|
+
command: String,
|
|
559
|
+
#[serde(default)]
|
|
560
|
+
commands: Vec<String>,
|
|
561
|
+
#[serde(default)]
|
|
562
|
+
steps: Vec<String>,
|
|
563
|
+
#[serde(default)]
|
|
564
|
+
file_content: String,
|
|
565
|
+
#[serde(default)]
|
|
566
|
+
summary: String,
|
|
567
|
+
#[serde(default)]
|
|
568
|
+
verification: String,
|
|
569
|
+
#[serde(default)]
|
|
570
|
+
start_line: Option<usize>,
|
|
571
|
+
#[serde(default)]
|
|
572
|
+
end_line: Option<usize>,
|
|
573
|
+
#[serde(default)]
|
|
574
|
+
limit: Option<usize>,
|
|
575
|
+
#[serde(default)]
|
|
576
|
+
patch: Option<AgentPatch>,
|
|
577
|
+
#[serde(default)]
|
|
578
|
+
server: String,
|
|
579
|
+
#[serde(default)]
|
|
580
|
+
tool: String,
|
|
581
|
+
#[serde(default)]
|
|
582
|
+
arguments: Value,
|
|
583
|
+
#[serde(default)]
|
|
584
|
+
note_path: String,
|
|
585
|
+
#[serde(default)]
|
|
586
|
+
name: String,
|
|
587
|
+
#[serde(default)]
|
|
588
|
+
instruction: String,
|
|
589
|
+
#[serde(default)]
|
|
590
|
+
title: String,
|
|
591
|
+
#[serde(default)]
|
|
592
|
+
status: String,
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
#[derive(Debug, Deserialize, Serialize)]
|
|
596
|
+
#[serde(rename_all = "camelCase")]
|
|
597
|
+
struct AgentPatch {
|
|
598
|
+
path: PathBuf,
|
|
599
|
+
#[serde(default)]
|
|
600
|
+
hunks: Vec<CodePatchHunk>,
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
fn deserialize_agent_input<'de, D>(deserializer: D) -> Result<AgentInput, D::Error>
|
|
604
|
+
where
|
|
605
|
+
D: serde::Deserializer<'de>,
|
|
606
|
+
{
|
|
607
|
+
Ok(Option::<AgentInput>::deserialize(deserializer)?.unwrap_or_default())
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
pub async fn orchestrate_agent_loop<Approve, Progress, Chunk>(
|
|
611
|
+
config: &MintConfig,
|
|
612
|
+
task: &str,
|
|
613
|
+
root: &Path,
|
|
614
|
+
image_data_uri: Option<String>,
|
|
615
|
+
chat_id: Option<&str>,
|
|
616
|
+
fast_mode: bool,
|
|
617
|
+
mut approve: Approve,
|
|
618
|
+
mut progress: Progress,
|
|
619
|
+
mut on_chunk: Chunk,
|
|
620
|
+
) -> Result<AgentResult, OrchestrationError>
|
|
621
|
+
where
|
|
622
|
+
Approve: FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send,
|
|
623
|
+
Progress: FnMut(AgentProgress) + Send,
|
|
624
|
+
Chunk: FnMut(String) + Send,
|
|
625
|
+
{
|
|
626
|
+
let started_at = Instant::now();
|
|
627
|
+
let root = root.canonicalize().map_err(|e| {
|
|
628
|
+
OrchestrationError::Agent(format!(
|
|
629
|
+
"unable to resolve workspace root {}: {}",
|
|
630
|
+
root.display(),
|
|
631
|
+
e
|
|
632
|
+
))
|
|
633
|
+
})?;
|
|
634
|
+
let resolved_task = resolve_github_links(task, config).await;
|
|
635
|
+
let skills = crate::skills::learned_skills_context().unwrap_or_default();
|
|
636
|
+
let mut observation = initial_observation(&resolved_task, &root, &skills);
|
|
637
|
+
let mut pending_image = image_data_uri;
|
|
638
|
+
|
|
639
|
+
let mut system_prompt = build_system_prompt(config);
|
|
640
|
+
let chat_id = chat_id
|
|
641
|
+
.map(str::trim)
|
|
642
|
+
.filter(|chat_id| !chat_id.is_empty())
|
|
643
|
+
.unwrap_or(DEFAULT_CONVERSATION_ID);
|
|
644
|
+
|
|
645
|
+
if let Ok(memory) = MemoryStore::open_default() {
|
|
646
|
+
let mut profile_instructions = String::new();
|
|
647
|
+
if let Ok(Some(name)) = memory.get_profile("name") {
|
|
648
|
+
if !name.trim().is_empty() {
|
|
649
|
+
profile_instructions.push_str(&format!("User Name: {}\n", name.trim()));
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if let Ok(Some(preferences)) = memory.get_profile("preferences") {
|
|
653
|
+
if !preferences.trim().is_empty() {
|
|
654
|
+
profile_instructions.push_str(&format!(
|
|
655
|
+
"User Preferences & Profile:\n{}\n",
|
|
656
|
+
preferences.trim()
|
|
657
|
+
));
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if !profile_instructions.is_empty() {
|
|
662
|
+
system_prompt = format!(
|
|
663
|
+
"{}\n\nUser Profile Information:\n{}",
|
|
664
|
+
system_prompt.trim(),
|
|
665
|
+
profile_instructions.trim()
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if let Ok(mut interactions) = memory.recent_interactions_for_chat(chat_id, 6) {
|
|
670
|
+
interactions.reverse();
|
|
671
|
+
let transcript = interactions
|
|
672
|
+
.into_iter()
|
|
673
|
+
.map(|item| format!("User: {}\nAssistant: {}", item.user_text, item.ai_text))
|
|
674
|
+
.collect::<Vec<_>>()
|
|
675
|
+
.join("\n\n");
|
|
676
|
+
if !transcript.is_empty() {
|
|
677
|
+
system_prompt = format!(
|
|
678
|
+
"{}\n\nRecent conversation context:\n{}",
|
|
679
|
+
system_prompt.trim(),
|
|
680
|
+
transcript
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
#[allow(unused_assignments)]
|
|
687
|
+
let mut final_provider = config.ai_provider.clone();
|
|
688
|
+
#[allow(unused_assignments)]
|
|
689
|
+
let mut final_model = "".to_string();
|
|
690
|
+
let mut final_fallback = None;
|
|
691
|
+
let mut action_counts = BTreeMap::<String, usize>::new();
|
|
692
|
+
let mut trajectory: Vec<String> = Vec::new();
|
|
693
|
+
|
|
694
|
+
for step in 1..=MAX_STEPS {
|
|
695
|
+
progress(AgentProgress::Thinking {
|
|
696
|
+
elapsed_secs: started_at.elapsed().as_secs(),
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
let (response, fallback) = send_chat_with_fallback(
|
|
700
|
+
config,
|
|
701
|
+
&ChatRequest {
|
|
702
|
+
message: observation.clone(),
|
|
703
|
+
system_instruction: system_prompt.clone(),
|
|
704
|
+
chat_id: Some(chat_id.to_owned()),
|
|
705
|
+
image_data_uri: pending_image.take(),
|
|
706
|
+
audio_data_uri: None,
|
|
707
|
+
document_attachment: None,
|
|
708
|
+
workspace_path: None,
|
|
709
|
+
},
|
|
710
|
+
)
|
|
711
|
+
.await?;
|
|
712
|
+
|
|
713
|
+
final_provider = response.provider.clone();
|
|
714
|
+
final_model = response.model.clone();
|
|
715
|
+
if fallback.is_some() {
|
|
716
|
+
final_fallback = response.fallback_provider.clone();
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
let decision = match parse_decision_or_finish(&response.text) {
|
|
720
|
+
Ok(decision) => decision,
|
|
721
|
+
Err(_) => {
|
|
722
|
+
let (repaired, _) = send_chat_with_fallback(
|
|
723
|
+
config,
|
|
724
|
+
&ChatRequest {
|
|
725
|
+
message: format!(
|
|
726
|
+
"Your previous response was not valid Mint agent JSON.\n\
|
|
727
|
+
Return exactly one corrected JSON object with an action and input. \
|
|
728
|
+
Do not use markdown.\n\nPrevious response:\n{}",
|
|
729
|
+
truncate(&response.text)
|
|
730
|
+
),
|
|
731
|
+
system_instruction: system_prompt.clone(),
|
|
732
|
+
chat_id: Some(chat_id.to_owned()),
|
|
733
|
+
image_data_uri: None,
|
|
734
|
+
audio_data_uri: None,
|
|
735
|
+
document_attachment: None,
|
|
736
|
+
workspace_path: None,
|
|
737
|
+
},
|
|
738
|
+
)
|
|
739
|
+
.await?;
|
|
740
|
+
parse_decision_or_finish(&repaired.text).map_err(|e| {
|
|
741
|
+
OrchestrationError::Agent(format!(
|
|
742
|
+
"unable to repair invalid agent response: {}",
|
|
743
|
+
e
|
|
744
|
+
))
|
|
745
|
+
})?
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
if !fast_mode && decision.action != "finish" && !decision.thought.trim().is_empty() {
|
|
750
|
+
progress(AgentProgress::Thought {
|
|
751
|
+
thought: decision.thought.trim().to_owned(),
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if decision.action == "finish" {
|
|
756
|
+
let mut summary = decision.input.summary.trim().to_owned();
|
|
757
|
+
let is_thai_task = task.chars().any(|c| ('\u{0e00}'..='\u{0e7f}').contains(&c));
|
|
758
|
+
if let Some(err_line) = observation
|
|
759
|
+
.lines()
|
|
760
|
+
.find(|l| l.contains("Web search error:"))
|
|
761
|
+
{
|
|
762
|
+
let clean_err = err_line
|
|
763
|
+
.replace("Web search error: ", "")
|
|
764
|
+
.replace("Web search is currently unavailable.", "")
|
|
765
|
+
.trim()
|
|
766
|
+
.to_string();
|
|
767
|
+
if summary.is_empty() {
|
|
768
|
+
if is_thai_task {
|
|
769
|
+
summary = format!(
|
|
770
|
+
"การค้นหาข้อมูลจากเว็บล้มเหลวเนื่องจากข้อผิดพลาด: {}\nมิ้นท์ขออภัยด้วยนะคะที่ไม่สามารถค้นหาข้อมูลเรียลไทม์ให้ได้ในขณะนี้ค่ะ",
|
|
771
|
+
clean_err
|
|
772
|
+
);
|
|
773
|
+
} else {
|
|
774
|
+
summary = format!(
|
|
775
|
+
"Web search failed due to error: {}\nI apologize, but I cannot retrieve real-time information at the moment.",
|
|
776
|
+
clean_err
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
} else {
|
|
780
|
+
let err_lower = clean_err.to_lowercase();
|
|
781
|
+
let summary_lower = summary.to_lowercase();
|
|
782
|
+
let already_mentions_error = if is_thai_task {
|
|
783
|
+
summary_lower.contains("ล้มเหลว")
|
|
784
|
+
|| summary_lower.contains("ข้อผิดพลาด")
|
|
785
|
+
|| summary_lower.contains(&err_lower)
|
|
786
|
+
} else {
|
|
787
|
+
summary_lower.contains("fail")
|
|
788
|
+
|| summary_lower.contains("error")
|
|
789
|
+
|| summary_lower.contains(&err_lower)
|
|
790
|
+
};
|
|
791
|
+
if !already_mentions_error {
|
|
792
|
+
if is_thai_task {
|
|
793
|
+
summary.push_str(&format!(
|
|
794
|
+
"\n\n(การค้นหาเว็บล้มเหลวเนื่องจากข้อผิดพลาด: {})",
|
|
795
|
+
clean_err
|
|
796
|
+
));
|
|
797
|
+
} else {
|
|
798
|
+
summary.push_str(&format!(
|
|
799
|
+
"\n\n(Web search failed due to error: {})",
|
|
800
|
+
clean_err
|
|
801
|
+
));
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
} else {
|
|
806
|
+
if summary.is_empty() {
|
|
807
|
+
let err_msg = "Error: Your finish action summary was empty. \
|
|
808
|
+
You MUST provide a final answer, explanation, or response to the user's query \
|
|
809
|
+
in the 'summary' field of the 'finish' action input. Do not leave it empty.";
|
|
810
|
+
trajectory.push(format!(
|
|
811
|
+
"Step {step}:\n- Thought: {}\n- Action: {}\n- Observation: {}",
|
|
812
|
+
decision.thought.trim(),
|
|
813
|
+
decision.action,
|
|
814
|
+
err_msg
|
|
815
|
+
));
|
|
816
|
+
let history_str = trajectory.join("\n\n");
|
|
817
|
+
observation = format!(
|
|
818
|
+
"Task: {task}\nWorkspace: {}\n\nHere is the history of what you have done so far in this agent loop:\n\n{}\n\nProceed to the next step. If you have completed the task, use the 'finish' action.",
|
|
819
|
+
root.display(),
|
|
820
|
+
history_str
|
|
821
|
+
);
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
let mut provider_used = None;
|
|
825
|
+
for line in observation.lines() {
|
|
826
|
+
if line.contains("Web search succeeded using Google Search") {
|
|
827
|
+
provider_used = Some("Google");
|
|
828
|
+
} else if line.contains("Web search succeeded using Brave Search") {
|
|
829
|
+
provider_used = Some("Brave");
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
if let Some(prov) = provider_used {
|
|
833
|
+
let summary_lower = summary.to_lowercase();
|
|
834
|
+
if !summary_lower.contains("google") && !summary_lower.contains("brave") {
|
|
835
|
+
if is_thai_task {
|
|
836
|
+
summary.push_str(&format!(
|
|
837
|
+
"\n\n(มิ้นท์หาข้อมูลนี้มาจาก {} Search นะคะ 💖)",
|
|
838
|
+
prov
|
|
839
|
+
));
|
|
840
|
+
} else {
|
|
841
|
+
summary.push_str(&format!(
|
|
842
|
+
"\n\n(Information retrieved via {} Search 💖)",
|
|
843
|
+
prov
|
|
844
|
+
));
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
let verification = meaningful_verification(&decision.input.verification).to_owned();
|
|
850
|
+
|
|
851
|
+
on_chunk(summary.clone());
|
|
852
|
+
|
|
853
|
+
let memory = MemoryStore::open_default()?;
|
|
854
|
+
memory.add_interaction_for_chat_with_fallback(
|
|
855
|
+
chat_id,
|
|
856
|
+
task,
|
|
857
|
+
&summary,
|
|
858
|
+
&final_provider,
|
|
859
|
+
&final_model,
|
|
860
|
+
final_fallback.as_deref(),
|
|
861
|
+
)?;
|
|
862
|
+
memory.save_workspace_session(&root.to_string_lossy(), &summary, &verification)?;
|
|
863
|
+
spawn_auto_memory_update(config.clone(), task.to_string(), summary.clone());
|
|
864
|
+
|
|
865
|
+
return Ok(AgentResult {
|
|
866
|
+
provider: final_provider,
|
|
867
|
+
model: final_model,
|
|
868
|
+
summary,
|
|
869
|
+
verification,
|
|
870
|
+
fallback: final_fallback,
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
let action_key = action_fingerprint(&decision);
|
|
875
|
+
let action_count = {
|
|
876
|
+
let count = action_counts.entry(action_key).or_insert(0);
|
|
877
|
+
*count += 1;
|
|
878
|
+
*count
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
let result = if decision.action == "run_shell" && action_count > 1 {
|
|
882
|
+
format!(
|
|
883
|
+
"Skipped duplicate shell command: {}\n\n[System Tip: This exact shell command already ran once in this task. Do not run it again. Use the finish action now and tell the user the action was completed.]",
|
|
884
|
+
decision.input.command.trim()
|
|
885
|
+
)
|
|
886
|
+
} else {
|
|
887
|
+
let input_val = serde_json::to_value(&decision.input).unwrap_or(Value::Null);
|
|
888
|
+
progress(AgentProgress::ToolStart {
|
|
889
|
+
action: decision.action.clone(),
|
|
890
|
+
input: input_val,
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
match execute_tool(&root, config, &decision, chat_id, &mut approve).await {
|
|
894
|
+
Ok(result) => result,
|
|
895
|
+
Err(error) => {
|
|
896
|
+
format!("Error: {}", error)
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
|
|
901
|
+
progress(AgentProgress::ToolEnd {
|
|
902
|
+
action: decision.action.clone(),
|
|
903
|
+
input: serde_json::to_value(&decision.input).unwrap_or(Value::Null),
|
|
904
|
+
result: result.clone(),
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
let mut final_result = truncate(&result);
|
|
908
|
+
if decision.action == "run_shell" || decision.action == "verify" {
|
|
909
|
+
let mut failed = false;
|
|
910
|
+
for line in result.lines() {
|
|
911
|
+
if line.starts_with("exit: ") {
|
|
912
|
+
let exit_code = line.replace("exit: ", "").trim().to_string();
|
|
913
|
+
if exit_code != "0" && exit_code != "unknown" {
|
|
914
|
+
failed = true;
|
|
915
|
+
}
|
|
916
|
+
break;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
if failed {
|
|
920
|
+
final_result.push_str(
|
|
921
|
+
"\n\n[System Tip: The command failed with a non-zero exit code. \
|
|
922
|
+
Analyze the stdout/stderr above to locate the error, read the offending files, \
|
|
923
|
+
apply corrected edits (using apply_patch), and run the verification command again. \
|
|
924
|
+
Do not finish or stop until the compilation or test errors are resolved!]"
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
if decision.action == "apply_patch" || decision.action == "write_file" {
|
|
929
|
+
final_result.push_str(
|
|
930
|
+
"\n\n[System Tip: The file edit was approved and applied successfully. \
|
|
931
|
+
If this satisfies the user's request, use the finish action now. \
|
|
932
|
+
Do not broaden the scope, do not make additional unrelated edits, and do not reread \
|
|
933
|
+
the same file unless you need one concise verification read.]",
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
if action_count >= 3 {
|
|
937
|
+
final_result.push_str(
|
|
938
|
+
"\n\n[System Tip: You repeated the same tool action three or more times. \
|
|
939
|
+
Stop repeating it. If you already have enough information or the requested edit is done, \
|
|
940
|
+
use the finish action now. Otherwise choose a different necessary action.]",
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
trajectory.push(format!(
|
|
945
|
+
"Step {step}:\n- Thought: {}\n- Action: {}\n- Observation: {}",
|
|
946
|
+
decision.thought.trim(),
|
|
947
|
+
decision.action,
|
|
948
|
+
final_result
|
|
949
|
+
));
|
|
950
|
+
|
|
951
|
+
let history_str = trajectory.join("\n\n");
|
|
952
|
+
observation = format!(
|
|
953
|
+
"Task: {task}\nWorkspace: {}\n\nHere is the history of what you have done so far in this agent loop:\n\n{}\n\nProceed to the next step. If you have completed the task, use the 'finish' action.",
|
|
954
|
+
root.display(),
|
|
955
|
+
history_str
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
Err(OrchestrationError::Agent(format!(
|
|
960
|
+
"code agent reached the limit of {} steps",
|
|
961
|
+
MAX_STEPS
|
|
962
|
+
)))
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
async fn execute_tool<Approve>(
|
|
966
|
+
root: &Path,
|
|
967
|
+
config: &MintConfig,
|
|
968
|
+
decision: &AgentDecision,
|
|
969
|
+
chat_id: &str,
|
|
970
|
+
approve_cb: &mut Approve,
|
|
971
|
+
) -> Result<String, OrchestrationError>
|
|
972
|
+
where
|
|
973
|
+
Approve: FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send,
|
|
974
|
+
{
|
|
975
|
+
let input = &decision.input;
|
|
976
|
+
match decision.action.as_str() {
|
|
977
|
+
"list_files" => {
|
|
978
|
+
let path = agent_read_path(root, &input.path, config)?;
|
|
979
|
+
let entries = list_directory_entries(&path, input.limit.unwrap_or(100), config)?;
|
|
980
|
+
Ok(serde_json::to_string_pretty(&entries)
|
|
981
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?)
|
|
982
|
+
}
|
|
983
|
+
"read_file" => {
|
|
984
|
+
let path = workspace_path(root, required(&input.path, "path")?)?;
|
|
985
|
+
Ok(read_code_file(
|
|
986
|
+
&path,
|
|
987
|
+
input.start_line.unwrap_or(1),
|
|
988
|
+
input.end_line.unwrap_or(240),
|
|
989
|
+
config,
|
|
990
|
+
)
|
|
991
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?)
|
|
992
|
+
}
|
|
993
|
+
"search_code" => {
|
|
994
|
+
let path = workspace_path(root, &input.path)?;
|
|
995
|
+
Ok(serde_json::to_string_pretty(
|
|
996
|
+
&search_code(
|
|
997
|
+
&path,
|
|
998
|
+
required(&input.query, "query")?,
|
|
999
|
+
input.limit.unwrap_or(20),
|
|
1000
|
+
config,
|
|
1001
|
+
)
|
|
1002
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?,
|
|
1003
|
+
)
|
|
1004
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?)
|
|
1005
|
+
}
|
|
1006
|
+
"symbols" => {
|
|
1007
|
+
let path = workspace_path(root, &input.path)?;
|
|
1008
|
+
Ok(serde_json::to_string_pretty(
|
|
1009
|
+
&build_symbol_index(&path, input.limit.unwrap_or(100), config)
|
|
1010
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?,
|
|
1011
|
+
)
|
|
1012
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?)
|
|
1013
|
+
}
|
|
1014
|
+
"semantic_index" => {
|
|
1015
|
+
let path = workspace_path(root, &input.path)?;
|
|
1016
|
+
Ok(serde_json::to_string_pretty(
|
|
1017
|
+
&index_semantic_code(&path, config)
|
|
1018
|
+
.await
|
|
1019
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?,
|
|
1020
|
+
)
|
|
1021
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?)
|
|
1022
|
+
}
|
|
1023
|
+
"semantic_search" => {
|
|
1024
|
+
let path = workspace_path(root, &input.path)?;
|
|
1025
|
+
Ok(serde_json::to_string_pretty(
|
|
1026
|
+
&search_semantic_code(
|
|
1027
|
+
&path,
|
|
1028
|
+
required(&input.query, "query")?,
|
|
1029
|
+
input.limit.unwrap_or(5),
|
|
1030
|
+
config,
|
|
1031
|
+
)
|
|
1032
|
+
.await
|
|
1033
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?,
|
|
1034
|
+
)
|
|
1035
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?)
|
|
1036
|
+
}
|
|
1037
|
+
"knowledge_search" => Ok(serde_json::to_string_pretty(
|
|
1038
|
+
&KnowledgeStore::open_default()
|
|
1039
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?
|
|
1040
|
+
.search(required(&input.query, "query")?, input.limit.unwrap_or(5))
|
|
1041
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?,
|
|
1042
|
+
)
|
|
1043
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?),
|
|
1044
|
+
"web_search" => {
|
|
1045
|
+
let query = required(&input.query, "query")?;
|
|
1046
|
+
let limit = input.limit.unwrap_or(5);
|
|
1047
|
+
match crate::web_search::search(query, limit, config).await {
|
|
1048
|
+
Ok((hits, provider)) => {
|
|
1049
|
+
if hits.is_empty() {
|
|
1050
|
+
Ok("No web search results found.".to_owned())
|
|
1051
|
+
} else {
|
|
1052
|
+
let formatted: String = hits
|
|
1053
|
+
.iter()
|
|
1054
|
+
.enumerate()
|
|
1055
|
+
.map(|(i, h)| {
|
|
1056
|
+
format!(
|
|
1057
|
+
"{}. {}\n URL: {}\n {}\n",
|
|
1058
|
+
i + 1,
|
|
1059
|
+
h.title,
|
|
1060
|
+
h.url,
|
|
1061
|
+
h.snippet
|
|
1062
|
+
)
|
|
1063
|
+
})
|
|
1064
|
+
.collect::<Vec<_>>()
|
|
1065
|
+
.join("\n");
|
|
1066
|
+
Ok(format!(
|
|
1067
|
+
"{formatted}\n\nNote: Web search succeeded using {provider} Search. In your finish summary, you MUST state that you found this information using the {provider} Search API (e.g. \"มิ้นท์ค้นหาข้อมูลนี้มาจาก {provider} Search นะคะ\")."
|
|
1068
|
+
))
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
Err(e) => Ok(format!(
|
|
1072
|
+
"Web search error: {e}. Web search is currently unavailable. \
|
|
1073
|
+
Do not try to search again. You MUST now proceed by calling the 'finish' action. \
|
|
1074
|
+
In your finish summary, explain to the user in Thai that the web search failed (mentioning the search error: {e}), \
|
|
1075
|
+
and then answer their query using your own pre-existing knowledge/database."
|
|
1076
|
+
)),
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
"memory_recall" => {
|
|
1080
|
+
let query = required(&input.query, "query")?;
|
|
1081
|
+
let query_lower = query.to_ascii_lowercase();
|
|
1082
|
+
let mut results = Vec::new();
|
|
1083
|
+
|
|
1084
|
+
if let Ok(memory) = MemoryStore::open_default() {
|
|
1085
|
+
if let Ok(interactions) = memory.recent_interactions_for_chat(chat_id, 50) {
|
|
1086
|
+
for item in interactions.iter().rev() {
|
|
1087
|
+
if item.user_text.to_ascii_lowercase().contains(&query_lower)
|
|
1088
|
+
|| item.ai_text.to_ascii_lowercase().contains(&query_lower)
|
|
1089
|
+
{
|
|
1090
|
+
results.push(format!(
|
|
1091
|
+
"[{}] You: {}\nMint: {}",
|
|
1092
|
+
&item.created_at[..16.min(item.created_at.len())],
|
|
1093
|
+
if item.user_text.len() > 200 {
|
|
1094
|
+
format!("{}…", &item.user_text[..200])
|
|
1095
|
+
} else {
|
|
1096
|
+
item.user_text.clone()
|
|
1097
|
+
},
|
|
1098
|
+
if item.ai_text.len() > 200 {
|
|
1099
|
+
format!("{}…", &item.ai_text[..200])
|
|
1100
|
+
} else {
|
|
1101
|
+
item.ai_text.clone()
|
|
1102
|
+
},
|
|
1103
|
+
));
|
|
1104
|
+
if results.len() >= 5 {
|
|
1105
|
+
break;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
if let Ok(skills) = memory.learned_skills(20) {
|
|
1112
|
+
for skill in &skills {
|
|
1113
|
+
if skill.content.to_ascii_lowercase().contains(&query_lower)
|
|
1114
|
+
|| skill.name.to_ascii_lowercase().contains(&query_lower)
|
|
1115
|
+
{
|
|
1116
|
+
results.push(format!(
|
|
1117
|
+
"[Skill: {}]\n{}",
|
|
1118
|
+
skill.name,
|
|
1119
|
+
if skill.content.len() > 300 {
|
|
1120
|
+
format!("{}…", &skill.content[..300])
|
|
1121
|
+
} else {
|
|
1122
|
+
skill.content.clone()
|
|
1123
|
+
}
|
|
1124
|
+
));
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
if results.is_empty() {
|
|
1131
|
+
Ok(format!("No memory found matching: {query}"))
|
|
1132
|
+
} else {
|
|
1133
|
+
Ok(results.join("\n\n"))
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
"git_status" => run_git(root, &["status", "--short", "--branch"]),
|
|
1137
|
+
"git_diff" => {
|
|
1138
|
+
if input.path.trim().is_empty() {
|
|
1139
|
+
run_git(root, &["diff", "--"])
|
|
1140
|
+
} else {
|
|
1141
|
+
let path = workspace_path(root, &input.path)?;
|
|
1142
|
+
let relative = path.strip_prefix(root).unwrap_or(&path).to_string_lossy();
|
|
1143
|
+
run_git(root, &["diff", "--", relative.as_ref()])
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
"git_log" => {
|
|
1147
|
+
let limit = input.limit.unwrap_or(5).clamp(1, 50).to_string();
|
|
1148
|
+
run_git(root, &["log", "-n", &limit, "--oneline", "--decorate"])
|
|
1149
|
+
}
|
|
1150
|
+
"git_branch" => run_git(root, &["branch", "--show-current"]),
|
|
1151
|
+
"create_plan" => Ok(serde_json::to_string_pretty(&serde_json::json!({
|
|
1152
|
+
"objective": input.summary,
|
|
1153
|
+
"steps": input.steps,
|
|
1154
|
+
}))
|
|
1155
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?),
|
|
1156
|
+
"update_plan" => Ok(serde_json::to_string_pretty(&serde_json::json!({
|
|
1157
|
+
"steps": input.steps,
|
|
1158
|
+
"status": input.status,
|
|
1159
|
+
}))
|
|
1160
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?),
|
|
1161
|
+
"request_user_approval" => {
|
|
1162
|
+
let title = if input.title.trim().is_empty() {
|
|
1163
|
+
"User approval"
|
|
1164
|
+
} else {
|
|
1165
|
+
input.title.trim()
|
|
1166
|
+
};
|
|
1167
|
+
let prompt = required(&input.summary, "summary")?;
|
|
1168
|
+
let approved = approve_cb(&AgentApproval::UserApproval {
|
|
1169
|
+
title: title.to_owned(),
|
|
1170
|
+
prompt: prompt.to_owned(),
|
|
1171
|
+
})
|
|
1172
|
+
.map_err(OrchestrationError::Agent)?;
|
|
1173
|
+
match approved {
|
|
1174
|
+
ApprovalOutcome::Approved => Ok(format!("User approved: {title}")),
|
|
1175
|
+
ApprovalOutcome::Denied => Ok(format!("User denied: {title}")),
|
|
1176
|
+
ApprovalOutcome::Intercepted(obs) => Ok(obs),
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
"ask_user" => {
|
|
1180
|
+
let question = required(&input.query, "query")?;
|
|
1181
|
+
let approved = approve_cb(&AgentApproval::AskUser {
|
|
1182
|
+
question: question.to_owned(),
|
|
1183
|
+
})
|
|
1184
|
+
.map_err(OrchestrationError::Agent)?;
|
|
1185
|
+
match approved {
|
|
1186
|
+
ApprovalOutcome::Approved => Ok("User approved the prompt.".into()),
|
|
1187
|
+
ApprovalOutcome::Denied => Ok("User declined to answer.".into()),
|
|
1188
|
+
ApprovalOutcome::Intercepted(answer) => Ok(format!("User answered: {answer}")),
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
"detect_project" => {
|
|
1192
|
+
let path = workspace_path(root, &input.path)?;
|
|
1193
|
+
Ok(serde_json::to_string_pretty(&detect_project(&path))
|
|
1194
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?)
|
|
1195
|
+
}
|
|
1196
|
+
"list_tests" => {
|
|
1197
|
+
let path = workspace_path(root, &input.path)?;
|
|
1198
|
+
Ok(serde_json::to_string_pretty(&list_tests(&path, config)?)
|
|
1199
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?)
|
|
1200
|
+
}
|
|
1201
|
+
"read_diagnostics" => {
|
|
1202
|
+
let path = workspace_path(root, &input.path)?;
|
|
1203
|
+
read_diagnostics(&path, config)
|
|
1204
|
+
}
|
|
1205
|
+
"view_image" => {
|
|
1206
|
+
let path = workspace_path(root, required(&input.path, "path")?)?;
|
|
1207
|
+
view_image(&path, config)
|
|
1208
|
+
}
|
|
1209
|
+
"note_write" => {
|
|
1210
|
+
let file_name = if !input.note_path.is_empty() {
|
|
1211
|
+
input.note_path.as_str()
|
|
1212
|
+
} else {
|
|
1213
|
+
required(&input.path, "path")?
|
|
1214
|
+
};
|
|
1215
|
+
if file_name.contains("..") || file_name.contains('/') {
|
|
1216
|
+
return Err(OrchestrationError::Agent(
|
|
1217
|
+
"note_write path must be a simple filename".into(),
|
|
1218
|
+
));
|
|
1219
|
+
}
|
|
1220
|
+
let notes_dir = dirs::config_dir()
|
|
1221
|
+
.ok_or_else(|| {
|
|
1222
|
+
OrchestrationError::Agent("cannot determine config directory".into())
|
|
1223
|
+
})?
|
|
1224
|
+
.join("mint")
|
|
1225
|
+
.join("notes");
|
|
1226
|
+
let note_path = notes_dir.join(file_name);
|
|
1227
|
+
|
|
1228
|
+
let approved = approve_cb(&AgentApproval::NoteWrite {
|
|
1229
|
+
path: file_name.to_owned(),
|
|
1230
|
+
content: input.file_content.clone(),
|
|
1231
|
+
})
|
|
1232
|
+
.map_err(|e| OrchestrationError::Agent(e))?;
|
|
1233
|
+
|
|
1234
|
+
match approved {
|
|
1235
|
+
ApprovalOutcome::Approved => {
|
|
1236
|
+
std::fs::create_dir_all(¬es_dir).map_err(|e| {
|
|
1237
|
+
OrchestrationError::Agent(format!("cannot create notes directory: {}", e))
|
|
1238
|
+
})?;
|
|
1239
|
+
std::fs::write(¬e_path, &input.file_content).map_err(|e| {
|
|
1240
|
+
OrchestrationError::Agent(format!("cannot write note: {}", e))
|
|
1241
|
+
})?;
|
|
1242
|
+
Ok(format!("Note saved to {}", note_path.display()))
|
|
1243
|
+
}
|
|
1244
|
+
ApprovalOutcome::Denied => Ok(format!("User denied note write: {}", file_name)),
|
|
1245
|
+
ApprovalOutcome::Intercepted(obs) => Ok(obs),
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
"run_plugin" => {
|
|
1249
|
+
let name = required(&input.name, "name")?;
|
|
1250
|
+
let instruction = required(&input.instruction, "instruction")?;
|
|
1251
|
+
let approved = approve_cb(&AgentApproval::RunPlugin {
|
|
1252
|
+
name: name.to_owned(),
|
|
1253
|
+
instruction: instruction.to_owned(),
|
|
1254
|
+
})
|
|
1255
|
+
.map_err(|e| OrchestrationError::Agent(e))?;
|
|
1256
|
+
|
|
1257
|
+
match approved {
|
|
1258
|
+
ApprovalOutcome::Approved => Ok(execute_native_plugin(config, name, instruction)
|
|
1259
|
+
.await
|
|
1260
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?),
|
|
1261
|
+
ApprovalOutcome::Denied => Ok(format!("User denied plugin execution: {}", name)),
|
|
1262
|
+
ApprovalOutcome::Intercepted(obs) => Ok(obs),
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
"mcp_tool" => {
|
|
1266
|
+
let server = required(&input.server, "server")?;
|
|
1267
|
+
let tool = required(&input.tool, "tool")?;
|
|
1268
|
+
let approved = approve_cb(&AgentApproval::McpTool {
|
|
1269
|
+
server: server.to_owned(),
|
|
1270
|
+
tool: tool.to_owned(),
|
|
1271
|
+
arguments: input.arguments.clone(),
|
|
1272
|
+
})
|
|
1273
|
+
.map_err(|e| OrchestrationError::Agent(e))?;
|
|
1274
|
+
|
|
1275
|
+
match approved {
|
|
1276
|
+
ApprovalOutcome::Approved => Ok(serde_json::to_string_pretty(
|
|
1277
|
+
&crate::mcp::call_mcp_tool(config, server, tool, input.arguments.clone())
|
|
1278
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?,
|
|
1279
|
+
)
|
|
1280
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?),
|
|
1281
|
+
ApprovalOutcome::Denied => {
|
|
1282
|
+
Ok(format!("User denied MCP tool call: {} {}", server, tool))
|
|
1283
|
+
}
|
|
1284
|
+
ApprovalOutcome::Intercepted(obs) => Ok(obs),
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
"run_shell" => {
|
|
1288
|
+
let command = required(&input.command, "command")?;
|
|
1289
|
+
let mode = classify_shell_command(command).mode.as_str().to_owned();
|
|
1290
|
+
let approved = approve_cb(&AgentApproval::RunShell {
|
|
1291
|
+
command: command.to_owned(),
|
|
1292
|
+
mode,
|
|
1293
|
+
})
|
|
1294
|
+
.map_err(|e| OrchestrationError::Agent(e))?;
|
|
1295
|
+
|
|
1296
|
+
match approved {
|
|
1297
|
+
ApprovalOutcome::Approved => run_shell(root, config, command),
|
|
1298
|
+
ApprovalOutcome::Denied => Ok(format!("User denied shell command: {}", command)),
|
|
1299
|
+
ApprovalOutcome::Intercepted(obs) => Ok(obs),
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
"verify" => {
|
|
1303
|
+
if input.commands.is_empty() {
|
|
1304
|
+
return Err(OrchestrationError::Agent(
|
|
1305
|
+
"verify requires at least one command".into(),
|
|
1306
|
+
));
|
|
1307
|
+
}
|
|
1308
|
+
let mut output = Vec::new();
|
|
1309
|
+
for command in &input.commands {
|
|
1310
|
+
output.push(run_shell(root, config, command)?);
|
|
1311
|
+
}
|
|
1312
|
+
Ok(output.join("\n\n"))
|
|
1313
|
+
}
|
|
1314
|
+
"apply_patch" => {
|
|
1315
|
+
let patch = input.patch.as_ref().ok_or_else(|| {
|
|
1316
|
+
OrchestrationError::Agent("apply_patch requires patch input".into())
|
|
1317
|
+
})?;
|
|
1318
|
+
if patch.hunks.is_empty() {
|
|
1319
|
+
return Err(OrchestrationError::Agent(
|
|
1320
|
+
"apply_patch requires at least one hunk".into(),
|
|
1321
|
+
));
|
|
1322
|
+
}
|
|
1323
|
+
let edit = build_code_patch(root, patch.path.clone(), &patch.hunks, config)
|
|
1324
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?;
|
|
1325
|
+
let proposal = propose_code_edits(root, std::slice::from_ref(&edit), config)
|
|
1326
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?;
|
|
1327
|
+
let diff = proposal
|
|
1328
|
+
.edits
|
|
1329
|
+
.iter()
|
|
1330
|
+
.map(|e| e.diff.clone())
|
|
1331
|
+
.collect::<Vec<_>>()
|
|
1332
|
+
.join("\n");
|
|
1333
|
+
|
|
1334
|
+
let approved = approve_cb(&AgentApproval::ApplyPatch {
|
|
1335
|
+
path: patch.path.to_string_lossy().into_owned(),
|
|
1336
|
+
hunks: patch.hunks.clone(),
|
|
1337
|
+
diff,
|
|
1338
|
+
})
|
|
1339
|
+
.map_err(|e| OrchestrationError::Agent(e))?;
|
|
1340
|
+
|
|
1341
|
+
match approved {
|
|
1342
|
+
ApprovalOutcome::Approved => {
|
|
1343
|
+
let applied = apply_code_edits(root, &[edit], &proposal.approval_token, config)
|
|
1344
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?;
|
|
1345
|
+
Ok(serde_json::to_string_pretty(&applied)
|
|
1346
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?)
|
|
1347
|
+
}
|
|
1348
|
+
ApprovalOutcome::Denied => {
|
|
1349
|
+
Ok(format!("User denied file edit: {}", edit.path.display()))
|
|
1350
|
+
}
|
|
1351
|
+
ApprovalOutcome::Intercepted(obs) => Ok(obs),
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
"write_file" => {
|
|
1355
|
+
let path_str = required(&input.path, "path")?;
|
|
1356
|
+
validate_new_workspace_file(root, config, Path::new(path_str))?;
|
|
1357
|
+
let edit = CodeEdit {
|
|
1358
|
+
path: PathBuf::from(path_str),
|
|
1359
|
+
content: input.file_content.clone(),
|
|
1360
|
+
};
|
|
1361
|
+
let proposal = propose_code_edits(root, std::slice::from_ref(&edit), config)
|
|
1362
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?;
|
|
1363
|
+
let diff = proposal
|
|
1364
|
+
.edits
|
|
1365
|
+
.iter()
|
|
1366
|
+
.map(|e| e.diff.clone())
|
|
1367
|
+
.collect::<Vec<_>>()
|
|
1368
|
+
.join("\n");
|
|
1369
|
+
|
|
1370
|
+
let approved = approve_cb(&AgentApproval::WriteFile {
|
|
1371
|
+
path: path_str.to_owned(),
|
|
1372
|
+
content: input.file_content.clone(),
|
|
1373
|
+
diff,
|
|
1374
|
+
})
|
|
1375
|
+
.map_err(|e| OrchestrationError::Agent(e))?;
|
|
1376
|
+
|
|
1377
|
+
match approved {
|
|
1378
|
+
ApprovalOutcome::Approved => {
|
|
1379
|
+
let applied = apply_code_edits(root, &[edit], &proposal.approval_token, config)
|
|
1380
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?;
|
|
1381
|
+
Ok(serde_json::to_string_pretty(&applied)
|
|
1382
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?)
|
|
1383
|
+
}
|
|
1384
|
+
ApprovalOutcome::Denied => Ok(format!("User denied file edit: {}", path_str)),
|
|
1385
|
+
ApprovalOutcome::Intercepted(obs) => Ok(obs),
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
other => Err(OrchestrationError::Agent(format!(
|
|
1389
|
+
"unsupported code-agent action '{}'",
|
|
1390
|
+
other
|
|
1391
|
+
))),
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
fn validate_new_workspace_file(
|
|
1396
|
+
root: &Path,
|
|
1397
|
+
config: &MintConfig,
|
|
1398
|
+
path: &Path,
|
|
1399
|
+
) -> Result<(), OrchestrationError> {
|
|
1400
|
+
let root = assert_path_capability(root, Capability::Write, config)
|
|
1401
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?;
|
|
1402
|
+
let target = assert_path_capability(&root.join(path), Capability::Write, config)
|
|
1403
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?;
|
|
1404
|
+
if !target.starts_with(&root) {
|
|
1405
|
+
return Err(OrchestrationError::Agent(format!(
|
|
1406
|
+
"write_file path escapes workspace root: {}",
|
|
1407
|
+
target.display()
|
|
1408
|
+
)));
|
|
1409
|
+
}
|
|
1410
|
+
if target.exists() {
|
|
1411
|
+
return Err(OrchestrationError::Agent(format!(
|
|
1412
|
+
"write_file can only create new files. Use apply_patch for existing file: {}",
|
|
1413
|
+
target.display()
|
|
1414
|
+
)));
|
|
1415
|
+
}
|
|
1416
|
+
Ok(())
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
fn run_git(root: &Path, args: &[&str]) -> Result<String, OrchestrationError> {
|
|
1420
|
+
let output = Command::new("git")
|
|
1421
|
+
.args(args)
|
|
1422
|
+
.current_dir(root)
|
|
1423
|
+
.output()
|
|
1424
|
+
.map_err(|e| OrchestrationError::Agent(format!("unable to run git: {e}")))?;
|
|
1425
|
+
Ok(format!(
|
|
1426
|
+
"exit: {}\nstdout:\n{}\nstderr:\n{}",
|
|
1427
|
+
output.status.code().unwrap_or(-1),
|
|
1428
|
+
String::from_utf8_lossy(&output.stdout),
|
|
1429
|
+
String::from_utf8_lossy(&output.stderr)
|
|
1430
|
+
))
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
fn detect_project(root: &Path) -> Value {
|
|
1434
|
+
let mut languages = Vec::new();
|
|
1435
|
+
let mut managers = Vec::new();
|
|
1436
|
+
let mut diagnostics = Vec::new();
|
|
1437
|
+
if root.join("Cargo.toml").exists() {
|
|
1438
|
+
languages.push("rust");
|
|
1439
|
+
managers.push("cargo");
|
|
1440
|
+
diagnostics.push("cargo check");
|
|
1441
|
+
}
|
|
1442
|
+
if root.join("package.json").exists() {
|
|
1443
|
+
languages.push("javascript/typescript");
|
|
1444
|
+
managers.push(if root.join("pnpm-lock.yaml").exists() {
|
|
1445
|
+
"pnpm"
|
|
1446
|
+
} else if root.join("yarn.lock").exists() {
|
|
1447
|
+
"yarn"
|
|
1448
|
+
} else {
|
|
1449
|
+
"npm"
|
|
1450
|
+
});
|
|
1451
|
+
diagnostics.push("npm run build or npm run typecheck");
|
|
1452
|
+
}
|
|
1453
|
+
if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() {
|
|
1454
|
+
languages.push("python");
|
|
1455
|
+
managers.push("pip/uv");
|
|
1456
|
+
diagnostics.push("pytest or python -m compileall");
|
|
1457
|
+
}
|
|
1458
|
+
serde_json::json!({
|
|
1459
|
+
"root": root,
|
|
1460
|
+
"languages": languages,
|
|
1461
|
+
"packageManagers": managers,
|
|
1462
|
+
"diagnostics": diagnostics,
|
|
1463
|
+
})
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
fn list_tests(root: &Path, config: &MintConfig) -> Result<Value, OrchestrationError> {
|
|
1467
|
+
let files = list_code_files(root, usize::MAX, config)
|
|
1468
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?;
|
|
1469
|
+
let test_files = files
|
|
1470
|
+
.into_iter()
|
|
1471
|
+
.filter(|file| {
|
|
1472
|
+
let path = file.path.to_string_lossy();
|
|
1473
|
+
path.contains("/tests/")
|
|
1474
|
+
|| path.ends_with("_test.rs")
|
|
1475
|
+
|| path.ends_with(".test.ts")
|
|
1476
|
+
|| path.ends_with(".test.tsx")
|
|
1477
|
+
|| path.ends_with(".spec.ts")
|
|
1478
|
+
|| path.ends_with(".spec.tsx")
|
|
1479
|
+
|| path.ends_with("_test.py")
|
|
1480
|
+
})
|
|
1481
|
+
.map(|file| file.path)
|
|
1482
|
+
.collect::<Vec<_>>();
|
|
1483
|
+
let package_scripts = package_test_scripts(root);
|
|
1484
|
+
Ok(serde_json::json!({
|
|
1485
|
+
"testFiles": test_files,
|
|
1486
|
+
"packageScripts": package_scripts,
|
|
1487
|
+
"cargo": root.join("Cargo.toml").exists(),
|
|
1488
|
+
}))
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
fn package_test_scripts(root: &Path) -> BTreeMap<String, String> {
|
|
1492
|
+
let path = root.join("package.json");
|
|
1493
|
+
let Ok(raw) = std::fs::read_to_string(path) else {
|
|
1494
|
+
return BTreeMap::new();
|
|
1495
|
+
};
|
|
1496
|
+
let Ok(value) = serde_json::from_str::<Value>(&raw) else {
|
|
1497
|
+
return BTreeMap::new();
|
|
1498
|
+
};
|
|
1499
|
+
value
|
|
1500
|
+
.get("scripts")
|
|
1501
|
+
.and_then(Value::as_object)
|
|
1502
|
+
.into_iter()
|
|
1503
|
+
.flatten()
|
|
1504
|
+
.filter(|(name, _)| {
|
|
1505
|
+
let lower = name.to_ascii_lowercase();
|
|
1506
|
+
lower.contains("test")
|
|
1507
|
+
|| lower.contains("check")
|
|
1508
|
+
|| lower.contains("lint")
|
|
1509
|
+
|| lower.contains("build")
|
|
1510
|
+
|| lower.contains("type")
|
|
1511
|
+
})
|
|
1512
|
+
.filter_map(|(name, command)| Some((name.clone(), command.as_str()?.to_owned())))
|
|
1513
|
+
.collect()
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
fn read_diagnostics(root: &Path, config: &MintConfig) -> Result<String, OrchestrationError> {
|
|
1517
|
+
let command = if root.join("Cargo.toml").exists() {
|
|
1518
|
+
Some("cargo check")
|
|
1519
|
+
} else {
|
|
1520
|
+
let scripts = package_test_scripts(root);
|
|
1521
|
+
if scripts.contains_key("typecheck") {
|
|
1522
|
+
Some("npm run -s typecheck")
|
|
1523
|
+
} else if scripts.contains_key("check") {
|
|
1524
|
+
Some("npm run -s check")
|
|
1525
|
+
} else if scripts.contains_key("build") {
|
|
1526
|
+
Some("npm run -s build")
|
|
1527
|
+
} else {
|
|
1528
|
+
None
|
|
1529
|
+
}
|
|
1530
|
+
};
|
|
1531
|
+
match command {
|
|
1532
|
+
Some(command) => run_shell(root, config, command),
|
|
1533
|
+
None => Ok("No diagnostics command detected.".into()),
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
fn view_image(path: &Path, config: &MintConfig) -> Result<String, OrchestrationError> {
|
|
1538
|
+
let path = assert_path_capability(path, Capability::Read, config)
|
|
1539
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?;
|
|
1540
|
+
let extension = path
|
|
1541
|
+
.extension()
|
|
1542
|
+
.and_then(|value| value.to_str())
|
|
1543
|
+
.unwrap_or_default()
|
|
1544
|
+
.to_ascii_lowercase();
|
|
1545
|
+
let mime = match extension.as_str() {
|
|
1546
|
+
"png" => "image/png",
|
|
1547
|
+
"jpg" | "jpeg" => "image/jpeg",
|
|
1548
|
+
"gif" => "image/gif",
|
|
1549
|
+
"webp" => "image/webp",
|
|
1550
|
+
"svg" => "image/svg+xml",
|
|
1551
|
+
_ => {
|
|
1552
|
+
return Err(OrchestrationError::Agent(format!(
|
|
1553
|
+
"unsupported image type: {}",
|
|
1554
|
+
path.display()
|
|
1555
|
+
)));
|
|
1556
|
+
}
|
|
1557
|
+
};
|
|
1558
|
+
let metadata = std::fs::metadata(&path)
|
|
1559
|
+
.map_err(|e| OrchestrationError::Agent(format!("cannot stat image: {e}")))?;
|
|
1560
|
+
if metadata.len() > 2_000_000 {
|
|
1561
|
+
return Ok(format!(
|
|
1562
|
+
"Image exists but is too large to inline ({} bytes): {}",
|
|
1563
|
+
metadata.len(),
|
|
1564
|
+
path.display()
|
|
1565
|
+
));
|
|
1566
|
+
}
|
|
1567
|
+
let bytes = std::fs::read(&path)
|
|
1568
|
+
.map_err(|e| OrchestrationError::Agent(format!("cannot read image: {e}")))?;
|
|
1569
|
+
Ok(serde_json::to_string_pretty(&serde_json::json!({
|
|
1570
|
+
"path": path,
|
|
1571
|
+
"bytes": bytes.len(),
|
|
1572
|
+
"mime": mime,
|
|
1573
|
+
"dataUri": format!("data:{mime};base64,{}", BASE64_STANDARD.encode(bytes)),
|
|
1574
|
+
}))
|
|
1575
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?)
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
fn run_shell(
|
|
1579
|
+
root: &Path,
|
|
1580
|
+
config: &MintConfig,
|
|
1581
|
+
command: &str,
|
|
1582
|
+
) -> Result<String, OrchestrationError> {
|
|
1583
|
+
let output = run_shell_command(command, root, true, config)
|
|
1584
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?;
|
|
1585
|
+
let status_str = output
|
|
1586
|
+
.status
|
|
1587
|
+
.map_or_else(|| "unknown".into(), |status| status.to_string());
|
|
1588
|
+
|
|
1589
|
+
let mut hint = "";
|
|
1590
|
+
let cmd_lower = command.to_lowercase();
|
|
1591
|
+
if output.success {
|
|
1592
|
+
if cmd_lower.contains("open")
|
|
1593
|
+
|| cmd_lower.contains("launch")
|
|
1594
|
+
|| cmd_lower.contains("chrome")
|
|
1595
|
+
|| cmd_lower.contains("firefox")
|
|
1596
|
+
{
|
|
1597
|
+
hint = "\nNote: Opening URLs, files, folders, or launching applications are background processes. Even if there are warnings or stdout/stderr outputs, since the command exited successfully with status 0, the operation has succeeded and you should now use the 'finish' action to inform the user.";
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
Ok(format!(
|
|
1602
|
+
"exit: {}\nmode: {}\nsandboxed: {}\nstdout:\n{}\nstderr:\n{}{}",
|
|
1603
|
+
status_str, output.mode, output.sandboxed, output.stdout, output.stderr, hint
|
|
1604
|
+
))
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
fn action_fingerprint(decision: &AgentDecision) -> String {
|
|
1608
|
+
let input = &decision.input;
|
|
1609
|
+
match decision.action.as_str() {
|
|
1610
|
+
"list_files" | "read_file" | "symbols" => {
|
|
1611
|
+
format!("{}:{}", decision.action, input.path.trim())
|
|
1612
|
+
}
|
|
1613
|
+
"search_code" | "semantic_search" | "web_search" | "knowledge_search" | "memory_recall" => {
|
|
1614
|
+
format!(
|
|
1615
|
+
"{}:{}:{}",
|
|
1616
|
+
decision.action,
|
|
1617
|
+
input.path.trim(),
|
|
1618
|
+
input.query.trim()
|
|
1619
|
+
)
|
|
1620
|
+
}
|
|
1621
|
+
"git_status" | "git_branch" | "detect_project" | "list_tests" | "read_diagnostics" => {
|
|
1622
|
+
format!("{}:{}", decision.action, input.path.trim())
|
|
1623
|
+
}
|
|
1624
|
+
"git_diff" => format!("git_diff:{}", input.path.trim()),
|
|
1625
|
+
"git_log" => format!("git_log:{}", input.limit.unwrap_or(5)),
|
|
1626
|
+
"create_plan" | "update_plan" => format!("{}:{}", decision.action, input.steps.join("\n")),
|
|
1627
|
+
"request_user_approval" => format!("request_user_approval:{}", input.summary.trim()),
|
|
1628
|
+
"ask_user" => format!("ask_user:{}", input.query.trim()),
|
|
1629
|
+
"view_image" => format!("view_image:{}", input.path.trim()),
|
|
1630
|
+
"run_shell" => format!("run_shell:{}", input.command.trim()),
|
|
1631
|
+
"verify" => format!("verify:{}", input.commands.join("\n")),
|
|
1632
|
+
"apply_patch" => input
|
|
1633
|
+
.patch
|
|
1634
|
+
.as_ref()
|
|
1635
|
+
.map(|patch| format!("apply_patch:{}", patch.path.display()))
|
|
1636
|
+
.unwrap_or_else(|| "apply_patch:<missing>".to_owned()),
|
|
1637
|
+
"write_file" => format!("write_file:{}", input.path.trim()),
|
|
1638
|
+
other => other.to_owned(),
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
fn initial_observation(task: &str, root: &Path, skills: &str) -> String {
|
|
1643
|
+
let mut observation = format!(
|
|
1644
|
+
"Task: {task}\nWorkspace: {}\nLearned skills:\n{}\n",
|
|
1645
|
+
root.display(),
|
|
1646
|
+
if skills.trim().is_empty() {
|
|
1647
|
+
"(none)"
|
|
1648
|
+
} else {
|
|
1649
|
+
skills
|
|
1650
|
+
}
|
|
1651
|
+
);
|
|
1652
|
+
if let Ok(memory) = MemoryStore::open_default() {
|
|
1653
|
+
if let Ok(Some(name)) = memory.get_profile("name") {
|
|
1654
|
+
observation.push_str(&format!("User Name: {name}\n"));
|
|
1655
|
+
}
|
|
1656
|
+
if let Ok(Some(session)) = memory.workspace_session(&root.to_string_lossy()) {
|
|
1657
|
+
observation.push_str(&format!(
|
|
1658
|
+
"Previous workspace session ({}):\nSummary: {}\nVerification: {}\n",
|
|
1659
|
+
session.updated_at,
|
|
1660
|
+
session.summary,
|
|
1661
|
+
if session.verification.trim().is_empty() {
|
|
1662
|
+
"(none)"
|
|
1663
|
+
} else {
|
|
1664
|
+
&session.verification
|
|
1665
|
+
}
|
|
1666
|
+
));
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
observation.push_str(&workspace_context(root));
|
|
1670
|
+
observation.push_str("Choose the first action. Finish immediately for casual conversation.");
|
|
1671
|
+
observation
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
fn workspace_context(root: &Path) -> String {
|
|
1675
|
+
let mut context = String::from("Automatic workspace context:\n");
|
|
1676
|
+
context.push_str(&format!(
|
|
1677
|
+
"Git status:\n{}\n",
|
|
1678
|
+
command_output(root, "git", &["status", "--short"])
|
|
1679
|
+
));
|
|
1680
|
+
context.push_str(&format!(
|
|
1681
|
+
"Diff summary:\n{}\n",
|
|
1682
|
+
command_output(root, "git", &["diff", "--stat"])
|
|
1683
|
+
));
|
|
1684
|
+
context.push_str(&format!("Package scripts:\n{}\n", package_scripts(root)));
|
|
1685
|
+
context
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
fn command_output(root: &Path, program: &str, args: &[&str]) -> String {
|
|
1689
|
+
use std::process::Command;
|
|
1690
|
+
match Command::new(program).args(args).current_dir(root).output() {
|
|
1691
|
+
Ok(output) if output.status.success() => {
|
|
1692
|
+
let value = String::from_utf8_lossy(&output.stdout);
|
|
1693
|
+
if value.trim().is_empty() {
|
|
1694
|
+
"(none)".into()
|
|
1695
|
+
} else {
|
|
1696
|
+
truncate(&value).trim().into()
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
_ => "(unavailable)".into(),
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
fn package_scripts(root: &Path) -> String {
|
|
1704
|
+
let Ok(raw) = std::fs::read_to_string(root.join("package.json")) else {
|
|
1705
|
+
return "(none)".into();
|
|
1706
|
+
};
|
|
1707
|
+
let Ok(value) = serde_json::from_str::<Value>(&raw) else {
|
|
1708
|
+
return "(invalid package.json)".into();
|
|
1709
|
+
};
|
|
1710
|
+
let Some(scripts) = value.get("scripts").and_then(Value::as_object) else {
|
|
1711
|
+
return "(none)".into();
|
|
1712
|
+
};
|
|
1713
|
+
scripts
|
|
1714
|
+
.iter()
|
|
1715
|
+
.map(|(name, command)| format!("{name}: {}", command.as_str().unwrap_or_default()))
|
|
1716
|
+
.collect::<Vec<_>>()
|
|
1717
|
+
.join("\n")
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
fn parse_decision(raw: &str) -> Result<AgentDecision, OrchestrationError> {
|
|
1721
|
+
if let Ok(decision) = parse_agent_json(raw) {
|
|
1722
|
+
return Ok(decision);
|
|
1723
|
+
}
|
|
1724
|
+
parse_shorthand_finish(raw).map_err(|e| OrchestrationError::Agent(e.to_string()))
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
fn parse_agent_json<T: serde::de::DeserializeOwned>(raw: &str) -> Result<T, OrchestrationError> {
|
|
1728
|
+
serde_json::from_str(raw).or_else(|_| {
|
|
1729
|
+
let start = raw
|
|
1730
|
+
.find('{')
|
|
1731
|
+
.ok_or_else(|| OrchestrationError::Agent("missing JSON object".into()))?;
|
|
1732
|
+
let end = raw
|
|
1733
|
+
.rfind('}')
|
|
1734
|
+
.ok_or_else(|| OrchestrationError::Agent("missing JSON object".into()))?;
|
|
1735
|
+
serde_json::from_str(&raw[start..=end])
|
|
1736
|
+
.map_err(|error| OrchestrationError::Agent(error.to_string()))
|
|
1737
|
+
})
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
fn parse_shorthand_finish(raw: &str) -> Result<AgentDecision, serde_json::Error> {
|
|
1741
|
+
let value: Value = serde_json::from_str(raw)?;
|
|
1742
|
+
let finish = value.get("finish").cloned().unwrap_or(Value::Null);
|
|
1743
|
+
let input = match finish {
|
|
1744
|
+
Value::Object(_) => serde_json::from_value(finish)?,
|
|
1745
|
+
Value::String(s) => AgentInput {
|
|
1746
|
+
summary: s,
|
|
1747
|
+
..AgentInput::default()
|
|
1748
|
+
},
|
|
1749
|
+
_ => AgentInput::default(),
|
|
1750
|
+
};
|
|
1751
|
+
Ok(AgentDecision {
|
|
1752
|
+
thought: value
|
|
1753
|
+
.get("thought")
|
|
1754
|
+
.and_then(Value::as_str)
|
|
1755
|
+
.unwrap_or_default()
|
|
1756
|
+
.into(),
|
|
1757
|
+
action: "finish".into(),
|
|
1758
|
+
input,
|
|
1759
|
+
})
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
fn parse_decision_or_finish(raw: &str) -> Result<AgentDecision, OrchestrationError> {
|
|
1763
|
+
match parse_decision(raw) {
|
|
1764
|
+
Ok(decision) => Ok(decision),
|
|
1765
|
+
Err(_) if !raw.trim().is_empty() && !raw.contains("\"action\"") => Ok(AgentDecision {
|
|
1766
|
+
thought: String::new(),
|
|
1767
|
+
action: "finish".into(),
|
|
1768
|
+
input: AgentInput {
|
|
1769
|
+
summary: raw.trim().into(),
|
|
1770
|
+
..AgentInput::default()
|
|
1771
|
+
},
|
|
1772
|
+
}),
|
|
1773
|
+
Err(error) => Err(error),
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
fn list_directory_entries(
|
|
1778
|
+
path: &Path,
|
|
1779
|
+
limit: usize,
|
|
1780
|
+
config: &MintConfig,
|
|
1781
|
+
) -> Result<Vec<AgentDirectoryEntry>, OrchestrationError> {
|
|
1782
|
+
let path = assert_path_capability(path, Capability::Read, config)
|
|
1783
|
+
.map_err(|e| OrchestrationError::Agent(e.to_string()))?;
|
|
1784
|
+
if !path.is_dir() {
|
|
1785
|
+
return Err(OrchestrationError::Agent(format!(
|
|
1786
|
+
"path is not a directory: {}",
|
|
1787
|
+
path.display()
|
|
1788
|
+
)));
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
let mut entries = Vec::new();
|
|
1792
|
+
let read_dir = std::fs::read_dir(&path).map_err(|e| {
|
|
1793
|
+
OrchestrationError::Agent(format!(
|
|
1794
|
+
"unable to read directory {}: {}",
|
|
1795
|
+
path.display(),
|
|
1796
|
+
e
|
|
1797
|
+
))
|
|
1798
|
+
})?;
|
|
1799
|
+
for entry in read_dir.take(limit.max(1)) {
|
|
1800
|
+
let entry = entry.map_err(|e| {
|
|
1801
|
+
OrchestrationError::Agent(format!("unable to read directory entry: {e}"))
|
|
1802
|
+
})?;
|
|
1803
|
+
let entry_path = entry.path();
|
|
1804
|
+
let file_type = entry.file_type().map_err(|e| {
|
|
1805
|
+
OrchestrationError::Agent(format!(
|
|
1806
|
+
"unable to read file type for {}: {}",
|
|
1807
|
+
entry_path.display(),
|
|
1808
|
+
e
|
|
1809
|
+
))
|
|
1810
|
+
})?;
|
|
1811
|
+
let size = if file_type.is_file() {
|
|
1812
|
+
entry.metadata().ok().map(|metadata| metadata.len())
|
|
1813
|
+
} else {
|
|
1814
|
+
None
|
|
1815
|
+
};
|
|
1816
|
+
entries.push(AgentDirectoryEntry {
|
|
1817
|
+
name: entry.file_name().to_string_lossy().into_owned(),
|
|
1818
|
+
path: entry_path,
|
|
1819
|
+
kind: if file_type.is_dir() {
|
|
1820
|
+
"directory"
|
|
1821
|
+
} else if file_type.is_file() {
|
|
1822
|
+
"file"
|
|
1823
|
+
} else if file_type.is_symlink() {
|
|
1824
|
+
"symlink"
|
|
1825
|
+
} else {
|
|
1826
|
+
"other"
|
|
1827
|
+
},
|
|
1828
|
+
size,
|
|
1829
|
+
});
|
|
1830
|
+
}
|
|
1831
|
+
entries.sort_by(|a, b| {
|
|
1832
|
+
a.kind
|
|
1833
|
+
.cmp(b.kind)
|
|
1834
|
+
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
|
1835
|
+
});
|
|
1836
|
+
Ok(entries)
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
fn agent_read_path(
|
|
1840
|
+
root: &Path,
|
|
1841
|
+
value: &str,
|
|
1842
|
+
config: &MintConfig,
|
|
1843
|
+
) -> Result<PathBuf, OrchestrationError> {
|
|
1844
|
+
let trimmed = value.trim();
|
|
1845
|
+
if trimmed.is_empty() || trimmed == "." {
|
|
1846
|
+
return workspace_path(root, ".");
|
|
1847
|
+
}
|
|
1848
|
+
if let Ok(path) = workspace_path(root, trimmed) {
|
|
1849
|
+
return Ok(path);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
let requested = Path::new(trimmed);
|
|
1853
|
+
let mut candidates = Vec::new();
|
|
1854
|
+
if let Some(home) = dirs::home_dir() {
|
|
1855
|
+
if trimmed == "~" {
|
|
1856
|
+
candidates.push(home.clone());
|
|
1857
|
+
} else if let Some(rest) = trimmed.strip_prefix("~/") {
|
|
1858
|
+
candidates.push(home.join(rest));
|
|
1859
|
+
} else if requested.components().count() == 1 {
|
|
1860
|
+
candidates.push(home.join(trimmed));
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
if requested.is_absolute() {
|
|
1864
|
+
candidates.push(requested.to_path_buf());
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
for candidate in candidates {
|
|
1868
|
+
let Ok(path) = candidate.canonicalize() else {
|
|
1869
|
+
continue;
|
|
1870
|
+
};
|
|
1871
|
+
if assert_path_capability(&path, Capability::Read, config).is_ok() {
|
|
1872
|
+
return Ok(path);
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
Err(OrchestrationError::Agent(format!(
|
|
1877
|
+
"unable to resolve readable path: {trimmed}"
|
|
1878
|
+
)))
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
fn meaningful_verification(value: &str) -> &str {
|
|
1882
|
+
let value = value.trim();
|
|
1883
|
+
if matches!(
|
|
1884
|
+
value.to_ascii_lowercase().as_str(),
|
|
1885
|
+
"" | "not run"
|
|
1886
|
+
| "not run."
|
|
1887
|
+
| "no checks run"
|
|
1888
|
+
| "no checks run."
|
|
1889
|
+
| "not_required"
|
|
1890
|
+
| "not required"
|
|
1891
|
+
| "none"
|
|
1892
|
+
| "n/a"
|
|
1893
|
+
) {
|
|
1894
|
+
""
|
|
1895
|
+
} else {
|
|
1896
|
+
value
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
fn workspace_path(root: &Path, value: &str) -> Result<PathBuf, OrchestrationError> {
|
|
1901
|
+
let path = root.join(if value.trim().is_empty() { "." } else { value });
|
|
1902
|
+
let path = path.canonicalize().map_err(|e| {
|
|
1903
|
+
OrchestrationError::Agent(format!(
|
|
1904
|
+
"unable to resolve workspace path {}: {}",
|
|
1905
|
+
path.display(),
|
|
1906
|
+
e
|
|
1907
|
+
))
|
|
1908
|
+
})?;
|
|
1909
|
+
if !path.starts_with(root) {
|
|
1910
|
+
return Err(OrchestrationError::Agent(format!(
|
|
1911
|
+
"path is outside workspace: {}",
|
|
1912
|
+
path.display()
|
|
1913
|
+
)));
|
|
1914
|
+
}
|
|
1915
|
+
Ok(path)
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
fn required<'a>(value: &'a str, name: &str) -> Result<&'a str, OrchestrationError> {
|
|
1919
|
+
if value.trim().is_empty() {
|
|
1920
|
+
return Err(OrchestrationError::Agent(format!("{} is required", name)));
|
|
1921
|
+
}
|
|
1922
|
+
Ok(value)
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
fn truncate(value: &str) -> String {
|
|
1926
|
+
if value.len() <= MAX_OBSERVATION_BYTES {
|
|
1927
|
+
value.into()
|
|
1928
|
+
} else {
|
|
1929
|
+
let mut end = MAX_OBSERVATION_BYTES;
|
|
1930
|
+
while !value.is_char_boundary(end) {
|
|
1931
|
+
end -= 1;
|
|
1932
|
+
}
|
|
1933
|
+
format!("{}\n...<truncated>", &value[..end])
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
pub fn spawn_auto_memory_update(config: MintConfig, user_text: String, ai_text: String) {
|
|
1938
|
+
tokio::spawn(async move {
|
|
1939
|
+
if let Err(e) = auto_extract_and_update_memory(&config, &user_text, &ai_text).await {
|
|
1940
|
+
eprintln!("Auto memory update failed: {:?}", e);
|
|
1941
|
+
}
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
pub async fn auto_extract_and_update_memory(
|
|
1946
|
+
config: &MintConfig,
|
|
1947
|
+
user_text: &str,
|
|
1948
|
+
ai_text: &str,
|
|
1949
|
+
) -> Result<(), OrchestrationError> {
|
|
1950
|
+
let memory = MemoryStore::open_default()?;
|
|
1951
|
+
|
|
1952
|
+
// Retrieve current profile values
|
|
1953
|
+
let current_name = memory
|
|
1954
|
+
.get_profile("name")
|
|
1955
|
+
.unwrap_or(None)
|
|
1956
|
+
.unwrap_or_default();
|
|
1957
|
+
let current_pref = memory
|
|
1958
|
+
.get_profile("preferences")
|
|
1959
|
+
.unwrap_or(None)
|
|
1960
|
+
.unwrap_or_default();
|
|
1961
|
+
|
|
1962
|
+
// System instruction for memory extraction
|
|
1963
|
+
let system_instruction = r#"You are a background agent responsible for updating a user's profile memory.
|
|
1964
|
+
Analyze the latest conversation turn below.
|
|
1965
|
+
Determine if the user shared their name, nickname, or any preferences, hobbies, or instructions on how they want the assistant to behave (e.g. language, formatting preference, details).
|
|
1966
|
+
Update the existing Profile Name and Profile Preferences accordingly.
|
|
1967
|
+
Keep existing preferences, add new ones, and resolve conflicts. Do not add metadata (like "preferred name") unless it is a generic preference. Keep formatting simple (e.g. list style or bullet points).
|
|
1968
|
+
You must return the updated profile strictly as a valid JSON object with keys:
|
|
1969
|
+
- "name": (string) updated name or same if not changed.
|
|
1970
|
+
- "preferences": (string) updated preferences list or same if not changed.
|
|
1971
|
+
|
|
1972
|
+
Format the response strictly as valid JSON, with no other text, markers, or markdown.
|
|
1973
|
+
Do NOT wrap the JSON in ```json ... ``` code blocks. Just output the raw JSON object.
|
|
1974
|
+
|
|
1975
|
+
Example response:
|
|
1976
|
+
{
|
|
1977
|
+
"name": "Pheem",
|
|
1978
|
+
"preferences": "Always explain code step-by-step. Prefers TypeScript. Default language is Thai."
|
|
1979
|
+
}"#.to_string();
|
|
1980
|
+
|
|
1981
|
+
let message = format!(
|
|
1982
|
+
"Current Name: {}\nCurrent Preferences:\n{}\n\nLatest Turn:\nUser: {}\nAssistant: {}",
|
|
1983
|
+
current_name, current_pref, user_text, ai_text
|
|
1984
|
+
);
|
|
1985
|
+
|
|
1986
|
+
let request = ChatRequest {
|
|
1987
|
+
message,
|
|
1988
|
+
system_instruction,
|
|
1989
|
+
chat_id: None,
|
|
1990
|
+
image_data_uri: None,
|
|
1991
|
+
audio_data_uri: None,
|
|
1992
|
+
document_attachment: None,
|
|
1993
|
+
workspace_path: None,
|
|
1994
|
+
};
|
|
1995
|
+
|
|
1996
|
+
// Send the chat request to LLM
|
|
1997
|
+
let response = send_chat(config, &request).await?;
|
|
1998
|
+
let text_reply = response.text.trim();
|
|
1999
|
+
|
|
2000
|
+
// Attempt to parse the JSON response
|
|
2001
|
+
let clean_json = if text_reply.starts_with("```") {
|
|
2002
|
+
let lines: Vec<&str> = text_reply.lines().collect();
|
|
2003
|
+
let mut filtered = Vec::new();
|
|
2004
|
+
for line in lines {
|
|
2005
|
+
let trimmed = line.trim();
|
|
2006
|
+
if !trimmed.starts_with("```") {
|
|
2007
|
+
filtered.push(trimmed);
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
filtered.join("\n")
|
|
2011
|
+
} else {
|
|
2012
|
+
text_reply.to_string()
|
|
2013
|
+
};
|
|
2014
|
+
|
|
2015
|
+
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&clean_json) {
|
|
2016
|
+
if let Some(obj) = value.as_object() {
|
|
2017
|
+
if let Some(new_name) = obj.get("name").and_then(|v| v.as_str()) {
|
|
2018
|
+
let trimmed_name = new_name.trim();
|
|
2019
|
+
if !trimmed_name.is_empty() && trimmed_name != current_name {
|
|
2020
|
+
memory.set_profile("name", trimmed_name)?;
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
if let Some(new_pref) = obj.get("preferences").and_then(|v| v.as_str()) {
|
|
2024
|
+
let trimmed_pref = new_pref.trim();
|
|
2025
|
+
if !trimmed_pref.is_empty() && trimmed_pref != current_pref {
|
|
2026
|
+
memory.set_profile("preferences", trimmed_pref)?;
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
Ok(())
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
#[cfg(test)]
|
|
2036
|
+
mod tests {
|
|
2037
|
+
use super::*;
|
|
2038
|
+
|
|
2039
|
+
#[test]
|
|
2040
|
+
fn preserves_request_without_history() {
|
|
2041
|
+
let store = MemoryStore::open(
|
|
2042
|
+
std::env::temp_dir().join(format!("mint-orchestrator-{}.sqlite", std::process::id())),
|
|
2043
|
+
);
|
|
2044
|
+
let request = ChatRequest {
|
|
2045
|
+
message: "hello".into(),
|
|
2046
|
+
system_instruction: "system".into(),
|
|
2047
|
+
chat_id: None,
|
|
2048
|
+
image_data_uri: None,
|
|
2049
|
+
audio_data_uri: None,
|
|
2050
|
+
document_attachment: None,
|
|
2051
|
+
workspace_path: None,
|
|
2052
|
+
};
|
|
2053
|
+
assert_eq!(
|
|
2054
|
+
enrich_request(&store, &request).unwrap().system_instruction,
|
|
2055
|
+
"system"
|
|
2056
|
+
);
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
#[test]
|
|
2060
|
+
fn agent_decision_allows_null_input() {
|
|
2061
|
+
let decision = parse_decision(r#"{"thought":"done","action":"finish","input":null}"#)
|
|
2062
|
+
.expect("null input should parse as default input");
|
|
2063
|
+
|
|
2064
|
+
assert_eq!(decision.action, "finish");
|
|
2065
|
+
assert!(decision.input.summary.is_empty());
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
#[test]
|
|
2069
|
+
fn agent_decision_allows_missing_input() {
|
|
2070
|
+
let decision = parse_decision(r#"{"thought":"done","action":"finish"}"#)
|
|
2071
|
+
.expect("missing input should parse as default input");
|
|
2072
|
+
|
|
2073
|
+
assert_eq!(decision.action, "finish");
|
|
2074
|
+
assert!(decision.input.summary.is_empty());
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
#[test]
|
|
2078
|
+
fn shorthand_finish_allows_null_or_missing_finish() {
|
|
2079
|
+
let decision = parse_decision(r#"{"thought":"done","finish":null}"#)
|
|
2080
|
+
.expect("null finish should parse");
|
|
2081
|
+
assert_eq!(decision.action, "finish");
|
|
2082
|
+
assert!(decision.input.summary.is_empty());
|
|
2083
|
+
|
|
2084
|
+
let decision =
|
|
2085
|
+
parse_decision(r#"{"thought":"done"}"#).expect("missing finish should parse");
|
|
2086
|
+
assert_eq!(decision.action, "finish");
|
|
2087
|
+
assert!(decision.input.summary.is_empty());
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
#[test]
|
|
2091
|
+
fn shorthand_finish_allows_string_finish_as_summary() {
|
|
2092
|
+
let decision = parse_decision(r#"{"thought":"done","finish":"all done!"}"#)
|
|
2093
|
+
.expect("string finish should parse as summary");
|
|
2094
|
+
assert_eq!(decision.action, "finish");
|
|
2095
|
+
assert_eq!(decision.input.summary, "all done!");
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
#[test]
|
|
2099
|
+
fn write_file_policy_rejects_existing_workspace_file() {
|
|
2100
|
+
let root =
|
|
2101
|
+
std::env::temp_dir().join(format!("mint-write-file-policy-{}", std::process::id()));
|
|
2102
|
+
std::fs::create_dir_all(&root).unwrap();
|
|
2103
|
+
let target = root.join("existing.txt");
|
|
2104
|
+
std::fs::write(&target, "already here").unwrap();
|
|
2105
|
+
let config = MintConfig {
|
|
2106
|
+
allowed_read_paths: vec![root.clone()],
|
|
2107
|
+
allowed_write_paths: vec![root.clone()],
|
|
2108
|
+
blocked_paths: vec![],
|
|
2109
|
+
blocked_file_names: vec![],
|
|
2110
|
+
..MintConfig::default()
|
|
2111
|
+
};
|
|
2112
|
+
|
|
2113
|
+
let result = validate_new_workspace_file(&root, &config, Path::new("existing.txt"));
|
|
2114
|
+
|
|
2115
|
+
assert!(
|
|
2116
|
+
matches!(result, Err(OrchestrationError::Agent(message)) if message.contains("Use apply_patch"))
|
|
2117
|
+
);
|
|
2118
|
+
let _ = std::fs::remove_file(target);
|
|
2119
|
+
let _ = std::fs::remove_dir(root);
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
#[test]
|
|
2123
|
+
fn agent_list_files_includes_directories() {
|
|
2124
|
+
let root = std::env::temp_dir().join(format!(
|
|
2125
|
+
"mint-agent-list-directories-{}",
|
|
2126
|
+
std::process::id()
|
|
2127
|
+
));
|
|
2128
|
+
std::fs::create_dir_all(root.join("Bunny Girl")).unwrap();
|
|
2129
|
+
std::fs::write(root.join("note.txt"), "hello").unwrap();
|
|
2130
|
+
let config = MintConfig {
|
|
2131
|
+
allowed_read_paths: vec![root.clone()],
|
|
2132
|
+
allowed_write_paths: vec![root.clone()],
|
|
2133
|
+
blocked_paths: vec![],
|
|
2134
|
+
blocked_file_names: vec![],
|
|
2135
|
+
..MintConfig::default()
|
|
2136
|
+
};
|
|
2137
|
+
|
|
2138
|
+
let entries = list_directory_entries(&root, 100, &config).unwrap();
|
|
2139
|
+
|
|
2140
|
+
assert!(entries.iter().any(|entry| {
|
|
2141
|
+
entry.name == "Bunny Girl" && entry.kind == "directory" && entry.size.is_none()
|
|
2142
|
+
}));
|
|
2143
|
+
assert!(entries.iter().any(|entry| {
|
|
2144
|
+
entry.name == "note.txt" && entry.kind == "file" && entry.size == Some(5)
|
|
2145
|
+
}));
|
|
2146
|
+
let _ = std::fs::remove_file(root.join("note.txt"));
|
|
2147
|
+
let _ = std::fs::remove_dir(root.join("Bunny Girl"));
|
|
2148
|
+
let _ = std::fs::remove_dir(root);
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
#[test]
|
|
2152
|
+
fn grep_is_classified_as_read_only() {
|
|
2153
|
+
let classification = classify_shell_command("ls ~/Downloads | grep -F \"Bunny Girl\"");
|
|
2154
|
+
|
|
2155
|
+
assert_eq!(classification.mode.as_str(), "readOnly");
|
|
2156
|
+
}
|
|
2157
|
+
}
|