feat: Improved workspace instructions support

This commit is contained in:
2026-07-17 14:52:35 -06:00
parent 8c885d9a77
commit a45e66c634
13 changed files with 364 additions and 81 deletions
+9
View File
@@ -69,6 +69,9 @@ pub struct AppConfig {
pub memory_cap_with_tools: Option<usize>,
pub memory_cap_without_tools: Option<usize>,
pub workspace_instructions: Option<bool>,
pub workspace_instructions_files: Option<Vec<String>>,
pub rag_embedding_model: Option<String>,
pub rag_reranker_model: Option<String>,
pub rag_top_k: usize,
@@ -146,6 +149,9 @@ impl Default for AppConfig {
memory_cap_with_tools: None,
memory_cap_without_tools: None,
workspace_instructions: None,
workspace_instructions_files: None,
rag_embedding_model: None,
rag_reranker_model: None,
rag_top_k: 5,
@@ -224,6 +230,9 @@ impl AppConfig {
memory_cap_with_tools: config.memory_cap_with_tools,
memory_cap_without_tools: config.memory_cap_without_tools,
workspace_instructions: config.workspace_instructions,
workspace_instructions_files: config.workspace_instructions_files,
rag_embedding_model: config.rag_embedding_model,
rag_reranker_model: config.rag_reranker_model,
rag_top_k: config.rag_top_k,
+203
View File
@@ -0,0 +1,203 @@
use std::fs;
use std::path::{Path, PathBuf};
use log::warn;
pub const WORKSPACE_INSTRUCTIONS_FILE_NAME: &str = "COYOTE.md";
pub const DEFAULT_WORKSPACE_INSTRUCTIONS_FILES: [&str; 3] =
[WORKSPACE_INSTRUCTIONS_FILE_NAME, "AGENTS.md", "CLAUDE.md"];
const INSTRUCTIONS_SIZE_WARN_THRESHOLD: usize = 24_000;
#[derive(Debug, Clone)]
pub struct WorkspaceInstructions {
pub path: PathBuf,
pub content: String,
}
pub fn default_workspace_instructions_files() -> Vec<String> {
DEFAULT_WORKSPACE_INSTRUCTIONS_FILES
.iter()
.map(|s| s.to_string())
.collect()
}
pub fn discover_workspace_instructions(
start: &Path,
file_names: &[String],
) -> Option<WorkspaceInstructions> {
for dir in start.ancestors() {
for name in file_names {
let candidate = dir.join(name);
if !candidate.is_file() {
continue;
}
match fs::read_to_string(&candidate) {
Ok(content) if !content.trim().is_empty() => {
return Some(WorkspaceInstructions {
path: candidate,
content,
});
}
Ok(_) => {}
Err(e) => warn!(
"failed to read workspace instructions at {}: {e}",
candidate.display()
),
}
}
}
None
}
pub fn build_instructions_section(instructions: &WorkspaceInstructions) -> String {
let char_count = instructions.content.chars().count();
if char_count > INSTRUCTIONS_SIZE_WARN_THRESHOLD {
warn!(
"workspace instructions at {} are large ({char_count} chars); \
consider moving detail into workspace memory drill files",
instructions.path.display()
);
}
format!(
"<workspace_instructions source=\"{}\">\n{}\n</workspace_instructions>",
instructions.path.display(),
instructions.content.trim_end()
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::{env, time};
use time::SystemTime;
fn temp_root(label: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = env::temp_dir().join(format!("coyote-instructions-{label}-{unique}"));
fs::create_dir_all(&root).unwrap();
root
}
fn defaults() -> Vec<String> {
default_workspace_instructions_files()
}
#[test]
fn discovery_returns_none_when_no_file_exists() {
let root = temp_root("none");
assert!(discover_workspace_instructions(&root, &defaults()).is_none());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn discovery_finds_coyote_md() {
let root = temp_root("coyote");
fs::write(root.join("COYOTE.md"), "coyote instructions").unwrap();
let found = discover_workspace_instructions(&root, &defaults()).unwrap();
assert_eq!(found.path, root.join("COYOTE.md"));
assert_eq!(found.content, "coyote instructions");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn discovery_falls_back_to_agents_md_then_claude_md() {
let root = temp_root("fallback");
fs::write(root.join("CLAUDE.md"), "claude instructions").unwrap();
let found = discover_workspace_instructions(&root, &defaults()).unwrap();
assert_eq!(found.path, root.join("CLAUDE.md"));
fs::write(root.join("AGENTS.md"), "agents instructions").unwrap();
let found = discover_workspace_instructions(&root, &defaults()).unwrap();
assert_eq!(found.path, root.join("AGENTS.md"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn discovery_prefers_coyote_md_over_fallbacks() {
let root = temp_root("precedence");
fs::write(root.join("COYOTE.md"), "coyote").unwrap();
fs::write(root.join("AGENTS.md"), "agents").unwrap();
fs::write(root.join("CLAUDE.md"), "claude").unwrap();
let found = discover_workspace_instructions(&root, &defaults()).unwrap();
assert_eq!(found.path, root.join("COYOTE.md"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn discovery_walks_up_from_nested_dir() {
let root = temp_root("walk_up");
fs::write(root.join("AGENTS.md"), "root instructions").unwrap();
let nested = root.join("src").join("deep");
fs::create_dir_all(&nested).unwrap();
let found = discover_workspace_instructions(&nested, &defaults()).unwrap();
assert_eq!(found.path, root.join("AGENTS.md"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn discovery_prefers_closer_file_over_higher_priority_name_above() {
let root = temp_root("depth_first");
fs::write(root.join("COYOTE.md"), "root coyote").unwrap();
let nested = root.join("packages").join("app");
fs::create_dir_all(&nested).unwrap();
fs::write(nested.join("CLAUDE.md"), "nested claude").unwrap();
let found = discover_workspace_instructions(&nested, &defaults()).unwrap();
assert_eq!(found.path, nested.join("CLAUDE.md"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn discovery_skips_empty_files() {
let root = temp_root("empty");
fs::write(root.join("COYOTE.md"), " \n").unwrap();
fs::write(root.join("AGENTS.md"), "real content").unwrap();
let found = discover_workspace_instructions(&root, &defaults()).unwrap();
assert_eq!(found.path, root.join("AGENTS.md"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn discovery_honors_custom_file_chain() {
let root = temp_root("custom");
fs::write(root.join("CLAUDE.md"), "claude").unwrap();
let only_agents = vec!["AGENTS.md".to_string()];
assert!(discover_workspace_instructions(&root, &only_agents).is_none());
let empty: Vec<String> = vec![];
assert!(discover_workspace_instructions(&root, &empty).is_none());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn build_section_wraps_content_with_source_path() {
let instructions = WorkspaceInstructions {
path: PathBuf::from("/ws/COYOTE.md"),
content: "Do the thing.\n".into(),
};
let section = build_instructions_section(&instructions);
assert!(section.starts_with("<workspace_instructions source=\"/ws/COYOTE.md\">"));
assert!(section.contains("Do the thing."));
assert!(section.ends_with("</workspace_instructions>"));
}
}
+17 -37
View File
@@ -7,41 +7,27 @@ use serde::{Deserialize, Serialize};
use crate::config::{
GIT_DIR_NAME, GITIGNORE_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME,
WORKSPACE_COYOTE_DIR_NAME, WORKSPACE_MEMORY_FILE_NAME, paths,
WORKSPACE_COYOTE_DIR_NAME, paths,
};
pub const DEFAULT_MEMORY_CAP_WITH_TOOLS: usize = 6_000;
pub const DEFAULT_MEMORY_CAP_WITHOUT_TOOLS: usize = 12_000;
#[derive(Debug, Clone)]
pub enum WorkspaceMemory {
Structured {
workspace_root: PathBuf,
dir: PathBuf,
},
Lite {
workspace_root: PathBuf,
file: PathBuf,
},
pub struct WorkspaceMemory {
pub workspace_root: PathBuf,
pub dir: PathBuf,
}
pub fn discover_workspace_memory(start: &Path) -> Option<WorkspaceMemory> {
for dir in start.ancestors() {
let structured = dir.join(WORKSPACE_COYOTE_DIR_NAME).join(MEMORY_DIR_NAME);
if structured.join(MEMORY_INDEX_FILE_NAME).exists() {
return Some(WorkspaceMemory::Structured {
return Some(WorkspaceMemory {
workspace_root: dir.to_path_buf(),
dir: structured,
});
}
let lite = dir.join(WORKSPACE_MEMORY_FILE_NAME);
if lite.exists() {
return Some(WorkspaceMemory::Lite {
workspace_root: dir.to_path_buf(),
file: lite,
});
}
}
None
}
@@ -82,7 +68,7 @@ pub fn bootstrap_workspace_memory(git_root: &Path) -> Result<PathBuf> {
Ok(mem_dir)
}
fn append_gitignore_entry(git_root: &Path) -> Result<bool> {
pub fn append_gitignore_entry(git_root: &Path) -> Result<bool> {
let gitignore = git_root.join(GITIGNORE_FILE_NAME);
let entry = format!("{WORKSPACE_COYOTE_DIR_NAME}/{MEMORY_DIR_NAME}/");
let entry_no_slash = format!("{WORKSPACE_COYOTE_DIR_NAME}/{MEMORY_DIR_NAME}");
@@ -212,9 +198,8 @@ impl MemoryStore {
pub fn load_workspace_index(&self) -> Result<Option<String>> {
match &self.workspace {
None => Ok(None),
Some(WorkspaceMemory::Lite { file, .. }) => Ok(Some(fs::read_to_string(file)?)),
Some(WorkspaceMemory::Structured { dir, .. }) => {
let index = dir.join(MEMORY_INDEX_FILE_NAME);
Some(ws) => {
let index = ws.dir.join(MEMORY_INDEX_FILE_NAME);
if index.exists() {
Ok(Some(fs::read_to_string(index)?))
} else {
@@ -231,8 +216,8 @@ impl MemoryStore {
collect_md_files(&self.global_dir, &mut out)?;
}
if let Some(WorkspaceMemory::Structured { dir, .. }) = &self.workspace {
collect_md_files(dir, &mut out)?;
if let Some(ws) = &self.workspace {
collect_md_files(&ws.dir, &mut out)?;
}
Ok(out)
@@ -378,18 +363,13 @@ mod tests {
}
#[test]
fn workspace_discovery_prefers_structured_over_lite() {
let root = temp_root("prefer");
fn workspace_discovery_ignores_root_instructions_file() {
let root = temp_root("no_lite");
let workspace = root.join("ws");
let structured = workspace
.join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME);
fs::create_dir_all(&structured).unwrap();
fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "s").unwrap();
fs::write(workspace.join(WORKSPACE_MEMORY_FILE_NAME), "l").unwrap();
fs::create_dir_all(&workspace).unwrap();
fs::write(workspace.join("COYOTE.md"), "instructions, not memory").unwrap();
let found = discover_workspace_memory(&workspace);
assert!(matches!(found, Some(WorkspaceMemory::Structured { .. })));
assert!(discover_workspace_memory(&workspace).is_none());
let _ = fs::remove_dir_all(&root);
}
@@ -582,8 +562,8 @@ mod tests {
let nested = workspace.join("src").join("deep").join("path");
fs::create_dir_all(&nested).unwrap();
let found = discover_workspace_memory(&nested);
assert!(matches!(found, Some(WorkspaceMemory::Structured { .. })));
let found = discover_workspace_memory(&nested).expect("workspace memory should be found");
assert_eq!(found.dir, mem_dir);
let _ = fs::remove_dir_all(&root);
}
+7 -1
View File
@@ -3,6 +3,7 @@ mod app_config;
mod app_state;
mod input;
mod install_remote;
pub(crate) mod instructions;
mod macros;
mod mcp_factory;
pub(crate) mod memory;
@@ -143,7 +144,6 @@ const MCP_FILE_NAME: &str = "mcp.json";
const HIDDEN_MCP_FILE_NAME: &str = ".mcp.json";
const MEMORY_DIR_NAME: &str = "memory";
const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md";
const WORKSPACE_MEMORY_FILE_NAME: &str = "COYOTE.md";
const WORKSPACE_COYOTE_DIR_NAME: &str = ".coyote";
const SBX_KIT_DIR_NAME: &str = "sbx-kit";
const SBX_KIT_HASH_FILE: &str = "kit.sha256";
@@ -245,6 +245,9 @@ pub struct Config {
pub memory_cap_with_tools: Option<usize>,
pub memory_cap_without_tools: Option<usize>,
pub workspace_instructions: Option<bool>,
pub workspace_instructions_files: Option<Vec<String>>,
pub rag_embedding_model: Option<String>,
pub rag_reranker_model: Option<String>,
pub rag_top_k: usize,
@@ -320,6 +323,9 @@ impl Default for Config {
memory_cap_with_tools: None,
memory_cap_without_tools: None,
workspace_instructions: None,
workspace_instructions_files: None,
rag_embedding_model: None,
rag_reranker_model: None,
rag_top_k: 5,
+4
View File
@@ -356,6 +356,10 @@ pub fn workspace_memory_dir_for(workspace_root: &Path) -> PathBuf {
.join(MEMORY_DIR_NAME)
}
pub fn workspace_memory_index_path_for(workspace_root: &Path) -> PathBuf {
workspace_memory_dir_for(workspace_root).join(MEMORY_INDEX_FILE_NAME)
}
pub fn repl_history_dir() -> PathBuf {
cache_path().join(REPL_HISTORY_DIR_NAME)
}
+33
View File
@@ -32,6 +32,7 @@ use crate::utils::{
list_file_names, now, render_prompt, temp_file,
};
use super::instructions;
use super::memory::{
DEFAULT_MEMORY_CAP_WITH_TOOLS, DEFAULT_MEMORY_CAP_WITHOUT_TOOLS, MemoryStore, WorkspaceMemory,
};
@@ -797,6 +798,20 @@ impl RequestContext {
self.session.as_ref(),
)?;
if app.workspace_instructions.unwrap_or(true)
&& let Ok(cwd) = env::current_dir()
{
let file_names = app
.workspace_instructions_files
.clone()
.unwrap_or_else(instructions::default_workspace_instructions_files);
if let Some(found) = instructions::discover_workspace_instructions(&cwd, &file_names) {
let separator = if role.is_empty_prompt() { "" } else { "\n\n" };
role.append_to_prompt(separator);
role.append_to_prompt(&instructions::build_instructions_section(&found));
}
}
if should_inject_skill_instructions(app, &policy) {
let config = self.skill_instructions_config();
@@ -1470,6 +1485,24 @@ impl RequestContext {
"memory_cap_without_tools",
super::format_option_value(&app.memory_cap_without_tools),
),
(
"workspace_instructions",
super::format_option_value(&app.workspace_instructions),
),
(
"workspace_instructions_file",
env::current_dir()
.ok()
.and_then(|cwd| {
let file_names = app
.workspace_instructions_files
.clone()
.unwrap_or_else(instructions::default_workspace_instructions_files);
instructions::discover_workspace_instructions(&cwd, &file_names)
})
.map(|i| i.path.display().to_string())
.unwrap_or_else(|| "null".into()),
),
(
"rag_reranker_model",
super::format_option_value(&rag_reranker_model),