feat: improved first-time run experience and included a templated configuration file that now has comments like the config.example.yaml so users don't have to go to the repo to see all the knobs
This commit is contained in:
@@ -69,7 +69,7 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
let mut functions = Functions::init(config.visible_tools.as_ref().unwrap_or(&Vec::new()))?;
|
||||
let mut functions = Functions::init(config.visible_tools.as_deref())?;
|
||||
if !mcp_registry.is_empty() && config.mcp_server_support {
|
||||
functions.append_mcp_meta_functions(mcp_registry.server_features());
|
||||
}
|
||||
|
||||
+91
-83
@@ -150,6 +150,9 @@ fn validate_no_template_in_secrets_provider(content: &str) -> Result<()> {
|
||||
const DARK_THEME: &[u8] = include_bytes!("../../assets/monokai-extended.theme.bin");
|
||||
const LIGHT_THEME: &[u8] = include_bytes!("../../assets/monokai-extended-light.theme.bin");
|
||||
|
||||
/// Fully documented config skeleton the first-run wizard splices dynamic values into.
|
||||
const CONFIG_TEMPLATE: &str = include_str!("../../assets/config-template.yaml");
|
||||
|
||||
const CONFIG_FILE_NAME: &str = "config.yaml";
|
||||
const AGENT_GRAPH_FILE_NAME: &str = "graph.yaml";
|
||||
const ROLES_DIR_NAME: &str = "roles";
|
||||
@@ -180,27 +183,6 @@ const BUNDLE_MANIFEST_FILE: &str = "coyote-bundle.yaml";
|
||||
const SBX_MIXIN_KITS_DIR_NAME: &str = "sbx-mixin-kits";
|
||||
const GIT_DIR_NAME: &str = ".git";
|
||||
const GITIGNORE_FILE_NAME: &str = ".gitignore";
|
||||
const DEFAULT_VISIBLE_TOOLS: [&str; 19] = [
|
||||
"execute_command.sh",
|
||||
"execute_py_code.py",
|
||||
"execute_sql_code.sh",
|
||||
"fetch_url_via_curl.sh",
|
||||
"fs_cat.sh",
|
||||
"fs_glob.sh",
|
||||
"fs_grep.sh",
|
||||
"fs_ls.sh",
|
||||
"fs_mkdir.sh",
|
||||
"fs_patch.sh",
|
||||
"fs_read.sh",
|
||||
"fs_rm.sh",
|
||||
"fs_write.sh",
|
||||
"ast_grep.sh",
|
||||
"get_current_time.sh",
|
||||
"get_current_weather.sh",
|
||||
"search_wikipedia.sh",
|
||||
"search_arxiv.sh",
|
||||
"web_search_coyote.sh",
|
||||
];
|
||||
|
||||
const CLIENTS_FIELD: &str = "clients";
|
||||
|
||||
@@ -811,51 +793,20 @@ pub async fn create_config_file(config_path: &Path) -> Result<()> {
|
||||
|
||||
let client = Select::new("API Provider (required):", list_client_types()).prompt()?;
|
||||
|
||||
let mut config = json!({});
|
||||
let (model, clients_config) = create_client_config(client, &vault).await?;
|
||||
config["model"] = model.into();
|
||||
match &provider_choice {
|
||||
None => {
|
||||
config["vault_password_file"] =
|
||||
vault.local_password_file()?.display().to_string().into();
|
||||
}
|
||||
let secrets = match &provider_choice {
|
||||
None => json!({
|
||||
"vault_password_file": vault.local_password_file()?.display().to_string()
|
||||
}),
|
||||
Some(provider) => {
|
||||
config["secrets_provider"] = serde_json::to_value(provider)
|
||||
let provider = serde_json::to_value(provider)
|
||||
.with_context(|| "failed to serialize secrets_provider config")?;
|
||||
json!({ "secrets_provider": provider })
|
||||
}
|
||||
}
|
||||
config["stream"] = json!(true);
|
||||
config["save"] = json!(true);
|
||||
config["keybindings"] = json!("vi");
|
||||
config["wrap"] = json!("auto");
|
||||
config["wrap_code"] = json!(false);
|
||||
config["function_calling_support"] = json!(true);
|
||||
config["enabled_tools"] = json!(null);
|
||||
config["visible_tools"] = json!(DEFAULT_VISIBLE_TOOLS);
|
||||
config["mcp_server_support"] = json!(true);
|
||||
config["enabled_mcp_servers"] = json!(null);
|
||||
config["highlight"] = json!(true);
|
||||
config["light_theme"] = json!(false);
|
||||
config[CLIENTS_FIELD] = clients_config;
|
||||
};
|
||||
|
||||
let config_data = serde_yaml::to_string(&config).with_context(|| "Failed to create config")?;
|
||||
let config_data = format!(
|
||||
"# see https://github.com/Dark-Alex-17/coyote/blob/main/config.example.yaml\n\n{config_data}"
|
||||
);
|
||||
|
||||
ensure_parent_exists(config_path)?;
|
||||
std::fs::write(config_path, config_data)
|
||||
.with_context(|| format!("Failed to write to '{}'", config_path.display()))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::prelude::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
std::fs::set_permissions(config_path, perms)?;
|
||||
}
|
||||
|
||||
println!("✓ Saved the config file to '{}'.\n", config_path.display());
|
||||
|
||||
Ok(())
|
||||
let config_data = render_config_template(&model, Some(&secrets), &clients_config)?;
|
||||
write_config_file(config_path, &config_data)
|
||||
}
|
||||
|
||||
async fn create_config_file_sandbox(config_path: &Path) -> Result<()> {
|
||||
@@ -865,7 +816,7 @@ async fn create_config_file_sandbox(config_path: &Path) -> Result<()> {
|
||||
"Running in sandbox mode — your API provider credentials are managed by your host Coyote configuration if configured."
|
||||
);
|
||||
|
||||
let oai_api_base = client::OPENAI_COMPATIBLE_PROVIDERS
|
||||
let oai_api_base = OPENAI_COMPATIBLE_PROVIDERS
|
||||
.iter()
|
||||
.find(|(name, _)| *name == client)
|
||||
.map(|(_, url)| *url);
|
||||
@@ -878,13 +829,13 @@ async fn create_config_file_sandbox(config_path: &Path) -> Result<()> {
|
||||
} else {
|
||||
api_base.to_string()
|
||||
};
|
||||
serde_json::json!({
|
||||
json!({
|
||||
"type": "openai-compatible",
|
||||
"name": client,
|
||||
"api_base": api_base_str,
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({ "type": client })
|
||||
json!({ "type": client })
|
||||
};
|
||||
|
||||
if client::client_type_supports_oauth(client) {
|
||||
@@ -898,27 +849,35 @@ async fn create_config_file_sandbox(config_path: &Path) -> Result<()> {
|
||||
|
||||
let model = set_client_models_config(&mut client_config, client).await?;
|
||||
|
||||
let mut config = serde_json::json!({});
|
||||
config["model"] = model.into();
|
||||
config["stream"] = serde_json::json!(true);
|
||||
config["save"] = serde_json::json!(true);
|
||||
config["keybindings"] = serde_json::json!("vi");
|
||||
config["wrap"] = serde_json::json!("auto");
|
||||
config["wrap_code"] = serde_json::json!(false);
|
||||
config["function_calling_support"] = serde_json::json!(true);
|
||||
config["enabled_tools"] = serde_json::json!(null);
|
||||
config["visible_tools"] = serde_json::json!(DEFAULT_VISIBLE_TOOLS);
|
||||
config["mcp_server_support"] = serde_json::json!(true);
|
||||
config["enabled_mcp_servers"] = serde_json::json!(null);
|
||||
config["highlight"] = serde_json::json!(true);
|
||||
config["light_theme"] = serde_json::json!(false);
|
||||
config[CLIENTS_FIELD] = serde_json::json!(vec![client_config]);
|
||||
let config_data = render_config_template(&model, None, &json!([client_config]))?;
|
||||
write_config_file(config_path, &config_data)
|
||||
}
|
||||
|
||||
let config_data = serde_yaml::to_string(&config).with_context(|| "Failed to create config")?;
|
||||
let config_data = format!(
|
||||
"# see https://github.com/Dark-Alex-17/coyote/blob/main/config.example.yaml\n\n{config_data}"
|
||||
);
|
||||
fn render_config_template(
|
||||
model: &str,
|
||||
secrets: Option<&serde_json::Value>,
|
||||
clients: &serde_json::Value,
|
||||
) -> Result<String> {
|
||||
let to_yaml = |value: &serde_json::Value| {
|
||||
serde_yaml::to_string(value).with_context(|| "Failed to create config")
|
||||
};
|
||||
|
||||
let model_block = to_yaml(&json!({ "model": model }))?;
|
||||
let secrets_block = match secrets {
|
||||
Some(value) => to_yaml(value)?,
|
||||
None => "# Sandbox mode: no vault provider is configured; secrets are provisioned\n\
|
||||
# from the host when the sandbox is created.\n"
|
||||
.to_string(),
|
||||
};
|
||||
let clients_block = to_yaml(&json!({ CLIENTS_FIELD: clients }))?;
|
||||
|
||||
Ok(CONFIG_TEMPLATE
|
||||
.replacen("__MODEL_BLOCK__\n", &model_block, 1)
|
||||
.replacen("__SECRETS_BLOCK__\n", &secrets_block, 1)
|
||||
.replacen("__CLIENTS_BLOCK__\n", &clients_block, 1))
|
||||
}
|
||||
|
||||
fn write_config_file(config_path: &Path, config_data: &str) -> Result<()> {
|
||||
ensure_parent_exists(config_path)?;
|
||||
std::fs::write(config_path, config_data)
|
||||
.with_context(|| format!("Failed to write to '{}'", config_path.display()))?;
|
||||
@@ -1174,6 +1133,55 @@ clients:
|
||||
assert_eq!(cfg.enabled_macros, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_template_renders_parseable_config() {
|
||||
let secrets = json!({ "vault_password_file": "/home/user/.coyote_password" });
|
||||
let clients = json!([{ "type": "openai", "api_key": "sk-test" }]);
|
||||
|
||||
let rendered = render_config_template("openai:gpt-4o", Some(&secrets), &clients).unwrap();
|
||||
|
||||
assert!(!rendered.contains("__MODEL_BLOCK__"));
|
||||
assert!(!rendered.contains("__SECRETS_BLOCK__"));
|
||||
assert!(!rendered.contains("__CLIENTS_BLOCK__"));
|
||||
|
||||
let cfg = Config::load_from_str(&rendered).unwrap();
|
||||
assert_eq!(cfg.model_id, "openai:gpt-4o");
|
||||
assert_eq!(
|
||||
cfg.vault_password_file,
|
||||
Some(PathBuf::from("/home/user/.coyote_password"))
|
||||
);
|
||||
assert!(cfg.secrets_provider.is_none());
|
||||
assert_eq!(cfg.keybindings, "emacs");
|
||||
assert!(cfg.save);
|
||||
assert_eq!(cfg.wrap.as_deref(), Some("auto"));
|
||||
assert!(cfg.visible_tools.is_none());
|
||||
assert!(cfg.mapping_tools.is_empty());
|
||||
assert!(cfg.document_loaders.is_empty());
|
||||
assert_eq!(cfg.compression_threshold, 4000);
|
||||
assert!(cfg.theme.is_none());
|
||||
assert_eq!(cfg.clients.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_template_renders_parseable_sandbox_config() {
|
||||
let clients = json!([{ "type": "claude" }]);
|
||||
|
||||
let rendered =
|
||||
render_config_template("claude:claude-sonnet-4-20250514", None, &clients).unwrap();
|
||||
|
||||
assert!(!rendered.contains("__SECRETS_BLOCK__"));
|
||||
|
||||
let cfg = Config::load_from_str(&rendered).unwrap();
|
||||
assert_eq!(cfg.model_id, "claude:claude-sonnet-4-20250514");
|
||||
assert!(cfg.vault_password_file.is_none());
|
||||
assert!(cfg.secrets_provider.is_none());
|
||||
assert_eq!(cfg.keybindings, "emacs");
|
||||
assert!(cfg.save);
|
||||
assert!(cfg.visible_tools.is_none());
|
||||
assert_eq!(cfg.compression_threshold, 4000);
|
||||
assert_eq!(cfg.clients.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_enabled_macros_empty_string_is_some_empty() {
|
||||
let cfg: Config = serde_yaml::from_str("enabled_macros: \"\"").unwrap();
|
||||
|
||||
@@ -4266,7 +4266,7 @@ impl RequestContext {
|
||||
}
|
||||
}
|
||||
|
||||
let mut functions = Functions::init(app.visible_tools.as_ref().unwrap_or(&Vec::new()))?;
|
||||
let mut functions = Functions::init(app.visible_tools.as_deref())?;
|
||||
if self.working_mode.is_repl() {
|
||||
functions.append_user_interaction_functions();
|
||||
}
|
||||
@@ -4687,7 +4687,7 @@ impl RequestContext {
|
||||
|
||||
pub fn exit_agent(&mut self, app: &AppConfig) -> Result<()> {
|
||||
self.exit_session()?;
|
||||
let mut functions = Functions::init(app.visible_tools.as_ref().unwrap_or(&Vec::new()))?;
|
||||
let mut functions = Functions::init(app.visible_tools.as_deref())?;
|
||||
if self.working_mode.is_repl() {
|
||||
functions.append_user_interaction_functions();
|
||||
}
|
||||
|
||||
+132
-34
@@ -214,6 +214,57 @@ fn tool_source_stems() -> Result<HashSet<String>> {
|
||||
Ok(stems)
|
||||
}
|
||||
|
||||
fn all_tool_source_files() -> Result<Vec<String>> {
|
||||
let tools_dir = paths::global_tools_dir();
|
||||
if !tools_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut file_names = Vec::new();
|
||||
for entry in fs::read_dir(&tools_dir)? {
|
||||
let path = entry?.path();
|
||||
if path.is_file()
|
||||
&& let Some(name) = path.file_name().and_then(OsStr::to_str)
|
||||
{
|
||||
file_names.push(name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(dedupe_tool_files_by_stem(file_names))
|
||||
}
|
||||
|
||||
fn dedupe_tool_files_by_stem(file_names: Vec<String>) -> Vec<String> {
|
||||
fn extension_rank(name: &str) -> Option<usize> {
|
||||
let ext = Path::new(name).extension().and_then(OsStr::to_str)?;
|
||||
match Language::from_extension(ext) {
|
||||
Language::Bash => Some(0),
|
||||
Language::Python => Some(1),
|
||||
Language::TypeScript => Some(2),
|
||||
Language::Unsupported => None,
|
||||
}
|
||||
}
|
||||
|
||||
let mut best: HashMap<String, (usize, String)> = HashMap::new();
|
||||
for name in file_names {
|
||||
let Some(rank) = extension_rank(&name) else {
|
||||
continue;
|
||||
};
|
||||
let Some(stem) = Path::new(&name).file_stem().and_then(OsStr::to_str) else {
|
||||
continue;
|
||||
};
|
||||
match best.get(stem) {
|
||||
Some((best_rank, _)) if *best_rank <= rank => {}
|
||||
_ => {
|
||||
best.insert(stem.to_string(), (rank, name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut files: Vec<String> = best.into_values().map(|(_, name)| name).collect();
|
||||
files.sort();
|
||||
files
|
||||
}
|
||||
|
||||
fn bin_entry_stem(file_name: &str) -> &str {
|
||||
let name = file_name.strip_prefix("run-").unwrap_or(file_name);
|
||||
Path::new(name)
|
||||
@@ -587,18 +638,23 @@ impl Functions {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn init(visible_tools: &[String]) -> Result<Self> {
|
||||
pub fn init(visible_tools: Option<&[String]>) -> Result<Self> {
|
||||
Self::remove_stale_global_function_binaries()?;
|
||||
|
||||
let (visible_tools, lenient) = match visible_tools {
|
||||
Some(tools) => (tools.to_vec(), false),
|
||||
None => (all_tool_source_files()?, true),
|
||||
};
|
||||
|
||||
let declarations = Self {
|
||||
declarations: Self::build_global_tool_declarations(visible_tools)?,
|
||||
declarations: Self::build_global_tool_declarations(&visible_tools, lenient)?,
|
||||
};
|
||||
|
||||
info!(
|
||||
"Building global function binaries in {}",
|
||||
paths::functions_bin_dir().display()
|
||||
);
|
||||
Self::build_global_function_binaries(visible_tools, None)?;
|
||||
Self::build_global_function_binaries(&visible_tools, None, lenient)?;
|
||||
|
||||
Ok(declarations)
|
||||
}
|
||||
@@ -608,13 +664,13 @@ impl Functions {
|
||||
|
||||
let global_tools_declarations = if !global_tools.is_empty() {
|
||||
info!("Loading global tools for agent: {name}: {global_tools:?}");
|
||||
let tools_declarations = Self::build_global_tool_declarations(global_tools)?;
|
||||
let tools_declarations = Self::build_global_tool_declarations(global_tools, false)?;
|
||||
|
||||
info!(
|
||||
"Building global function binaries required by agent: {name} in {}",
|
||||
paths::functions_bin_dir().display()
|
||||
);
|
||||
Self::build_global_function_binaries(global_tools, Some(name))?;
|
||||
Self::build_global_function_binaries(global_tools, Some(name), false)?;
|
||||
tools_declarations
|
||||
} else {
|
||||
debug!("No global tools found for agent: {}", name);
|
||||
@@ -964,13 +1020,17 @@ impl Functions {
|
||||
|
||||
fn build_global_tool_declarations(
|
||||
enabled_tools: &[String],
|
||||
lenient: bool,
|
||||
) -> Result<Vec<FunctionDeclaration>> {
|
||||
let global_tools_directory = paths::global_tools_dir();
|
||||
let mut function_declarations = Vec::new();
|
||||
|
||||
for tool in enabled_tools {
|
||||
let declaration = Self::generate_declarations(&global_tools_directory.join(tool))?;
|
||||
function_declarations.extend(declaration);
|
||||
match Self::generate_declarations(&global_tools_directory.join(tool)) {
|
||||
Ok(declaration) => function_declarations.extend(declaration),
|
||||
Err(err) if lenient => warn!("Skipping tool {tool}: {err}"),
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(function_declarations)
|
||||
@@ -1032,41 +1092,50 @@ impl Functions {
|
||||
fn build_global_function_binaries(
|
||||
enabled_tools: &[String],
|
||||
agent_name: Option<&str>,
|
||||
lenient: bool,
|
||||
) -> Result<()> {
|
||||
for tool in enabled_tools {
|
||||
let language = Language::from(
|
||||
&Path::new(&tool)
|
||||
.extension()
|
||||
.and_then(OsStr::to_str)
|
||||
.map(|s| s.to_lowercase())
|
||||
.ok_or_else(|| {
|
||||
anyhow::format_err!("Unable to extract file extension from path: {tool:?}")
|
||||
})?,
|
||||
);
|
||||
let binary_name = Path::new(&tool)
|
||||
.file_stem()
|
||||
.and_then(OsStr::to_str)
|
||||
.ok_or_else(|| {
|
||||
anyhow::format_err!("Unable to extract file name from path: {tool:?}")
|
||||
})?;
|
||||
|
||||
if language == Language::Unsupported {
|
||||
bail!("Unsupported tool file extension: {}", language.as_ref());
|
||||
match Self::build_global_function_binary(tool, agent_name) {
|
||||
Ok(()) => {}
|
||||
Err(err) if lenient => warn!("Skipping binary for tool {tool}: {err}"),
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
|
||||
let tool_path = paths::global_tools_dir().join(tool);
|
||||
let custom_runtime = extract_shebang_runtime(&tool_path);
|
||||
Self::build_binaries(
|
||||
binary_name,
|
||||
language,
|
||||
BinaryType::Tool(agent_name),
|
||||
custom_runtime.as_deref(),
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_global_function_binary(tool: &str, agent_name: Option<&str>) -> Result<()> {
|
||||
let language = Language::from(
|
||||
&Path::new(tool)
|
||||
.extension()
|
||||
.and_then(OsStr::to_str)
|
||||
.map(|s| s.to_lowercase())
|
||||
.ok_or_else(|| {
|
||||
anyhow::format_err!("Unable to extract file extension from path: {tool:?}")
|
||||
})?,
|
||||
);
|
||||
let binary_name = Path::new(tool)
|
||||
.file_stem()
|
||||
.and_then(OsStr::to_str)
|
||||
.ok_or_else(|| {
|
||||
anyhow::format_err!("Unable to extract file name from path: {tool:?}")
|
||||
})?;
|
||||
|
||||
if language == Language::Unsupported {
|
||||
bail!("Unsupported tool file extension: {}", language.as_ref());
|
||||
}
|
||||
|
||||
let tool_path = paths::global_tools_dir().join(tool);
|
||||
let custom_runtime = extract_shebang_runtime(&tool_path);
|
||||
Self::build_binaries(
|
||||
binary_name,
|
||||
language,
|
||||
BinaryType::Tool(agent_name),
|
||||
custom_runtime.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn remove_stale_agent_bin_entries(name: &str) -> Result<()> {
|
||||
let agent_bin_directory = paths::agent_bin_dir(name);
|
||||
|
||||
@@ -2603,6 +2672,35 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::{mem, process};
|
||||
|
||||
#[test]
|
||||
fn dedupe_tool_files_prefers_sh_over_py_over_ts() {
|
||||
let files = vec![
|
||||
"get_current_weather.ts".to_string(),
|
||||
"get_current_weather.py".to_string(),
|
||||
"get_current_weather.sh".to_string(),
|
||||
"fetch.ts".to_string(),
|
||||
"fetch.py".to_string(),
|
||||
"demo_ts.ts".to_string(),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
dedupe_tool_files_by_stem(files),
|
||||
vec!["demo_ts.ts", "fetch.py", "get_current_weather.sh"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedupe_tool_files_skips_unsupported_extensions() {
|
||||
let files = vec![
|
||||
"notes.md".to_string(),
|
||||
"tool.sh".to_string(),
|
||||
"README".to_string(),
|
||||
"archive.tar.gz".to_string(),
|
||||
];
|
||||
|
||||
assert_eq!(dedupe_tool_files_by_stem(files), vec!["tool.sh"]);
|
||||
}
|
||||
|
||||
fn call(name: &str, id: Option<&str>) -> ToolCall {
|
||||
ToolCall::new(name.to_string(), json!({}), id.map(|s| s.to_string()))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user