Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c979041161
|
||
|
|
a606ea552d
|
||
|
|
39a654a79e
|
||
|
|
17d1decce6
|
||
|
|
a45e66c634
|
||
|
|
8c885d9a77
|
@@ -38,6 +38,7 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g
|
||||
* [RAG](https://github.com/Dark-Alex-17/coyote/wiki/RAG): Retrieval-Augmented Generation for enhanced information retrieval and generation.
|
||||
* [Sessions](https://github.com/Dark-Alex-17/coyote/wiki/Sessions): Manage and persist conversational contexts and settings across multiple interactions.
|
||||
* [Memory](https://github.com/Dark-Alex-17/coyote/wiki/Memory): Persistent file-based memory that survives across sessions. Bootstrap with `coyote --init-memory [global|workspace]`.
|
||||
* [Workspace Instructions](https://github.com/Dark-Alex-17/coyote/wiki/Workspace-Instructions): Human-curated project instructions (`COYOTE.md`) injected into every prompt, with `AGENTS.md`/`CLAUDE.md`/`GEMINI.md` fallbacks for cross-tool compatibility. Scaffold with `coyote --init-instructions`.
|
||||
* [Roles](https://github.com/Dark-Alex-17/coyote/wiki/Roles): Customize model behavior for specific tasks or domains.
|
||||
* [Skills](https://github.com/Dark-Alex-17/coyote/wiki/Skills): Modular knowledge or capability packs the LLM can load and unload mid-conversation. Multiple skills compose; instructions stack, tools and MCPs union.
|
||||
* [Agents](https://github.com/Dark-Alex-17/coyote/wiki/Agents): Leverage AI agents to perform complex tasks and workflows, including sub-agent spawning, teammate messaging, and user interaction tools.
|
||||
|
||||
@@ -9,8 +9,8 @@ security/configuration settings. The analysis aims to ensure a thorough understa
|
||||
structured and operates, enabling the creation of new files, maintaining consistency with existing practices, and the
|
||||
potential implementation of best practices.
|
||||
|
||||
Should the root directory contain a `COYOTE.md` file, this was generated by Coyote and should be used as a reference
|
||||
point for all analysis, style questions, etc.
|
||||
Should the root directory contain a `COYOTE.md` (or `AGENTS.md`/`CLAUDE.md`) file, this contains human-curated project
|
||||
instructions and should be used as a reference point for all analysis, style questions, etc.
|
||||
|
||||
**Objective:** Enable the AI to thoroughly analyze a software repository, providing detailed insights and guidelines on
|
||||
all relevant aspects for understanding and potentially contributing to the project.
|
||||
|
||||
@@ -10,7 +10,8 @@ Use IWE tools when the task involves a corpus of markdown documents: plan reposi
|
||||
|
||||
Do NOT use IWE tools for:
|
||||
|
||||
- **Agent memory** (`.coyote/memory/`, `COYOTE.md`) — use the `memory__*` tools; they own the index conventions there.
|
||||
- **Agent memory** (`.coyote/memory/`) — use the `memory__*` tools; they own the index conventions there.
|
||||
- **Workspace instructions** (`COYOTE.md`, `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`) — human-curated and read-only; never edit them with IWE write tools.
|
||||
- **Semantic/similarity search over documents** — that is RAG's job. IWE search is fuzzy title/key matching plus structural traversal, not embeddings.
|
||||
- **Source code** — IWE only understands markdown.
|
||||
|
||||
|
||||
+21
-7
@@ -136,11 +136,13 @@ enabled_mcp_servers: null # Which MCP servers to enable by default.
|
||||
# - slack
|
||||
# Example (comma-separated form):
|
||||
# enabled_mcp_servers: github,slack,ddg-search
|
||||
no_workspace_mcp: false # Disable loading workspace-local MCP servers from .coyote/mcp.json (default: false).
|
||||
# When false (the default), Coyote merges .coyote/mcp.json from the current directory
|
||||
# into the global MCP registry at startup. If mcp.json is absent, Coyote falls back
|
||||
# to .coyote/.mcp.json (leading dot) for compatibility with Claude-style
|
||||
# configurations. Workspace entries shadow global ones on name collision.
|
||||
no_workspace_mcp: false # Disable loading workspace-local MCP servers (default: false).
|
||||
# When false (the default), Coyote merges the first workspace MCP config it finds
|
||||
# into the global MCP registry at startup, checking in order:
|
||||
# 1. .coyote/mcp.json
|
||||
# 2. .coyote/.mcp.json (Claude-style file name)
|
||||
# 3. .mcp.json (project root; Claude Code convention)
|
||||
# Workspace entries shadow global ones on name collision.
|
||||
# Set to true (or pass --no-workspace-mcp) to skip this entirely.
|
||||
|
||||
# ---- Skills ----
|
||||
@@ -187,8 +189,8 @@ summary_context_prompt: > # The text prompt used for including the summar
|
||||
|
||||
# ---- Memory ----
|
||||
# See the [Memory documentation](https://github.com/Dark-Alex-17/coyote/wiki/Memory) for more information.
|
||||
# Memory is opt-in by workspace presence (a `COYOTE.md` or `.coyote/memory/MEMORY.md`)
|
||||
# and global presence (`<config_dir>/memory/MEMORY.md`). Set `memory: false` to disable
|
||||
# Memory is opt-in by workspace presence (`.coyote/memory/MEMORY.md`) and global
|
||||
# presence (`<config_dir>/memory/MEMORY.md`). Set `memory: false` to disable
|
||||
# even when memory files exist. The cascade is: agent > session > role > app.
|
||||
# Bootstrap with `coyote --init-memory [global|workspace]` to create the marker file
|
||||
# the LLM needs before it will write any memory.
|
||||
@@ -198,6 +200,18 @@ memory_cap_with_tools: null # Char cap for injected memory when function ca
|
||||
memory_cap_without_tools: null # Char cap when function calling is unavailable (default: 12000).
|
||||
# Indexes plus drill file bodies are injected up to this cap.
|
||||
|
||||
# ---- Workspace Instructions ----
|
||||
# Human-curated project instructions injected read-only into the system prompt, in full.
|
||||
# Coyote walks up from the current directory and injects the first match from the file
|
||||
# chain below (per directory, in order). Scaffold with `coyote --init-instructions`.
|
||||
# Disable per-invocation with --no-workspace-instructions, or override the chain with
|
||||
# repeatable --workspace-instructions-file flags.
|
||||
workspace_instructions: null # null/true = inject when an instructions file exists; false = never inject
|
||||
workspace_instructions_files: null # File name chain to search, in priority order.
|
||||
# Default: [COYOTE.md, AGENTS.md, CLAUDE.md, GEMINI.md]
|
||||
# Set to a custom list to reorder or drop fallbacks, e.g.:
|
||||
# workspace_instructions_files: [COYOTE.md]
|
||||
|
||||
# ---- RAG ----
|
||||
# See the [RAG Docs](https://github.com/Dark-Alex-17/coyote/wiki/RAG) for more details.
|
||||
rag_embedding_model: null # Specifies the embedding model used for context retrieval
|
||||
|
||||
+10
-1
@@ -71,12 +71,18 @@ pub struct Cli {
|
||||
/// Display the message without sending it
|
||||
#[arg(long)]
|
||||
pub dry_run: bool,
|
||||
/// Disable loading workspace MCP servers from .coyote/mcp.json (or .coyote/.mcp.json)
|
||||
/// Disable loading workspace MCP servers from .coyote/mcp.json, .coyote/.mcp.json, or .mcp.json
|
||||
#[arg(long)]
|
||||
pub no_workspace_mcp: bool,
|
||||
/// Disable memory for this invocation
|
||||
#[arg(long)]
|
||||
pub no_memory: bool,
|
||||
/// Disable loading workspace instructions (COYOTE.md/AGENTS.md/CLAUDE.md/etc.) for this invocation
|
||||
#[arg(long)]
|
||||
pub no_workspace_instructions: bool,
|
||||
/// Override the workspace instructions file chain for this invocation (repeatable, priority order)
|
||||
#[arg(long, value_name = "NAME")]
|
||||
pub workspace_instructions_file: Vec<String>,
|
||||
/// Skip permission prompts by setting AUTO_CONFIRM for all tools (dangerous!)
|
||||
#[arg(long)]
|
||||
pub dangerously_skip_permissions: bool,
|
||||
@@ -98,6 +104,9 @@ pub struct Cli {
|
||||
help_heading = "Session & Memory"
|
||||
)]
|
||||
pub init_memory: Option<MemoryScope>,
|
||||
/// Scaffold a COYOTE.md workspace instructions file in the current directory
|
||||
#[arg(long, help_heading = "Session & Memory")]
|
||||
pub init_instructions: bool,
|
||||
/// Pre-load an existing skill into the session (repeatable). If a single
|
||||
/// `--skill <NAME>` is given and the skill doesn't exist, opens $EDITOR
|
||||
/// with a scaffold to create it.
|
||||
|
||||
+16
-13
@@ -43,6 +43,8 @@ pub struct Agent {
|
||||
graph_rags: HashMap<String, Arc<Rag>>,
|
||||
model: Model,
|
||||
vault: GlobalVault,
|
||||
is_graph: bool,
|
||||
enabled_tools: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
@@ -243,6 +245,8 @@ impl Agent {
|
||||
graph_rags,
|
||||
model,
|
||||
vault: app_state.vault.clone(),
|
||||
is_graph: graph_for_rag.is_some(),
|
||||
enabled_tools: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -339,6 +343,10 @@ impl Agent {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub fn is_graph(&self) -> bool {
|
||||
self.is_graph
|
||||
}
|
||||
|
||||
pub fn functions(&self) -> &Functions {
|
||||
&self.functions
|
||||
}
|
||||
@@ -580,7 +588,7 @@ impl RoleLike for Agent {
|
||||
}
|
||||
|
||||
fn enabled_tools(&self) -> Option<Vec<String>> {
|
||||
None
|
||||
self.enabled_tools.clone()
|
||||
}
|
||||
|
||||
fn enabled_mcp_servers(&self) -> Option<Vec<String>> {
|
||||
@@ -605,18 +613,13 @@ impl RoleLike for Agent {
|
||||
}
|
||||
|
||||
fn set_enabled_tools(&mut self, value: Option<Vec<String>>) {
|
||||
match value {
|
||||
Some(tools) => {
|
||||
self.config.global_tools = tools
|
||||
.into_iter()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
}
|
||||
None => {
|
||||
self.config.global_tools.clear();
|
||||
}
|
||||
}
|
||||
self.enabled_tools = value.map(|tools| {
|
||||
tools
|
||||
.into_iter()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
}
|
||||
|
||||
fn set_enabled_mcp_servers(&mut self, value: Option<Vec<String>>) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
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; 4] = [
|
||||
WORKSPACE_INSTRUCTIONS_FILE_NAME,
|
||||
"AGENTS.md",
|
||||
"CLAUDE.md",
|
||||
"GEMINI.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_through_chain_in_order() {
|
||||
let root = temp_root("fallback");
|
||||
fs::write(root.join("GEMINI.md"), "gemini instructions").unwrap();
|
||||
|
||||
let found = discover_workspace_instructions(&root, &defaults()).unwrap();
|
||||
assert_eq!(found.path, root.join("GEMINI.md"));
|
||||
|
||||
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
@@ -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
@@ -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,
|
||||
|
||||
+65
-19
@@ -213,11 +213,18 @@ pub fn workspace_skill_file(name: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
pub fn workspace_mcp_config_file() -> Option<PathBuf> {
|
||||
workspace_mcp_config_file_in(&env::current_dir().unwrap_or_default())
|
||||
}
|
||||
|
||||
fn workspace_mcp_config_file_in(workspace_root: &Path) -> Option<PathBuf> {
|
||||
let dir = workspace_config_dir();
|
||||
[MCP_FILE_NAME, HIDDEN_MCP_FILE_NAME]
|
||||
.into_iter()
|
||||
.map(|name| dir.join(name))
|
||||
.find(|candidate| candidate.is_file())
|
||||
[
|
||||
dir.join(MCP_FILE_NAME),
|
||||
dir.join(HIDDEN_MCP_FILE_NAME),
|
||||
workspace_root.join(HIDDEN_MCP_FILE_NAME),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.is_file())
|
||||
}
|
||||
|
||||
pub fn validate_skill_name(name: &str) -> Result<()> {
|
||||
@@ -349,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)
|
||||
}
|
||||
@@ -695,19 +706,20 @@ mod tests {
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
|
||||
fn with_workspace_dir<F: FnOnce(&Path)>(f: F) {
|
||||
fn with_workspace_dir<F: FnOnce(&Path, &Path)>(f: F) {
|
||||
let unique = time::SystemTime::now()
|
||||
.duration_since(time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let root = env::temp_dir().join(format!("coyote-workspace-mcp-test-{unique}"));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let ws_dir = root.join(WORKSPACE_COYOTE_DIR_NAME);
|
||||
fs::create_dir_all(&ws_dir).unwrap();
|
||||
let env_name = get_env_name("workspace_config_dir");
|
||||
let prev = env::var_os(&env_name);
|
||||
unsafe {
|
||||
env::set_var(&env_name, &root);
|
||||
env::set_var(&env_name, &ws_dir);
|
||||
}
|
||||
f(&root);
|
||||
f(&root, &ws_dir);
|
||||
unsafe {
|
||||
match prev {
|
||||
Some(v) => env::set_var(&env_name, v),
|
||||
@@ -720,36 +732,70 @@ mod tests {
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_none_when_no_config_exists() {
|
||||
with_workspace_dir(|_| {
|
||||
assert_eq!(workspace_mcp_config_file(), None);
|
||||
with_workspace_dir(|root, _| {
|
||||
assert_eq!(workspace_mcp_config_file_in(root), None);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn finds_mcp_json() {
|
||||
with_workspace_dir(|root| {
|
||||
fs::write(root.join("mcp.json"), "{}").unwrap();
|
||||
assert_eq!(workspace_mcp_config_file(), Some(root.join("mcp.json")));
|
||||
with_workspace_dir(|root, ws_dir| {
|
||||
fs::write(ws_dir.join("mcp.json"), "{}").unwrap();
|
||||
assert_eq!(
|
||||
workspace_mcp_config_file_in(root),
|
||||
Some(ws_dir.join("mcp.json"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn falls_back_to_claude_style_hidden_mcp_json() {
|
||||
with_workspace_dir(|root| {
|
||||
fs::write(root.join(".mcp.json"), "{}").unwrap();
|
||||
assert_eq!(workspace_mcp_config_file(), Some(root.join(".mcp.json")));
|
||||
with_workspace_dir(|root, ws_dir| {
|
||||
fs::write(ws_dir.join(".mcp.json"), "{}").unwrap();
|
||||
assert_eq!(
|
||||
workspace_mcp_config_file_in(root),
|
||||
Some(ws_dir.join(".mcp.json"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn prefers_mcp_json_when_both_exist() {
|
||||
with_workspace_dir(|root| {
|
||||
fs::write(root.join("mcp.json"), "{}").unwrap();
|
||||
with_workspace_dir(|root, ws_dir| {
|
||||
fs::write(ws_dir.join("mcp.json"), "{}").unwrap();
|
||||
fs::write(ws_dir.join(".mcp.json"), "{}").unwrap();
|
||||
assert_eq!(
|
||||
workspace_mcp_config_file_in(root),
|
||||
Some(ws_dir.join("mcp.json"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn falls_back_to_project_root_hidden_mcp_json() {
|
||||
with_workspace_dir(|root, _| {
|
||||
fs::write(root.join(".mcp.json"), "{}").unwrap();
|
||||
assert_eq!(workspace_mcp_config_file(), Some(root.join("mcp.json")));
|
||||
assert_eq!(
|
||||
workspace_mcp_config_file_in(root),
|
||||
Some(root.join(".mcp.json"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn prefers_workspace_dir_config_over_project_root() {
|
||||
with_workspace_dir(|root, ws_dir| {
|
||||
fs::write(ws_dir.join(".mcp.json"), "{}").unwrap();
|
||||
fs::write(root.join(".mcp.json"), "{}").unwrap();
|
||||
assert_eq!(
|
||||
workspace_mcp_config_file_in(root),
|
||||
Some(ws_dir.join(".mcp.json"))
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+170
-26
@@ -16,7 +16,8 @@ use super::{
|
||||
use super::{MessageContentToolCalls, prompts};
|
||||
use crate::client::{Model, ModelType, list_models};
|
||||
use crate::function::{
|
||||
FunctionDeclaration, Functions, ToolCallTracker, ToolResult, skill::SKILL_FUNCTION_PREFIX,
|
||||
FunctionDeclaration, Functions, ToolCallTracker, ToolResult, memory::MEMORY_FUNCTION_PREFIX,
|
||||
skill::SKILL_FUNCTION_PREFIX, supervisor::SUPERVISOR_FUNCTION_PREFIX,
|
||||
todo::TODO_FUNCTION_PREFIX, user_interaction::USER_FUNCTION_PREFIX,
|
||||
};
|
||||
use crate::mcp::{
|
||||
@@ -32,6 +33,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 +799,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();
|
||||
|
||||
@@ -1032,15 +1048,19 @@ impl RequestContext {
|
||||
}
|
||||
|
||||
fn concrete_tool_names(&self) -> Vec<String> {
|
||||
self.tool_scope
|
||||
.functions
|
||||
.declarations()
|
||||
let declarations = match &self.agent {
|
||||
Some(agent) => agent.functions().declarations(),
|
||||
None => self.tool_scope.functions.declarations(),
|
||||
};
|
||||
declarations
|
||||
.iter()
|
||||
.filter(|v| {
|
||||
!v.name.starts_with("user__")
|
||||
&& !v.name.starts_with("mcp_")
|
||||
&& !v.name.starts_with("todo__")
|
||||
&& !v.name.starts_with("agent__")
|
||||
&& !v.name.starts_with("memory__")
|
||||
&& !v.name.starts_with("skill__")
|
||||
})
|
||||
.map(|v| v.name.clone())
|
||||
.collect()
|
||||
@@ -1100,20 +1120,32 @@ impl RequestContext {
|
||||
_ => bail!("Unknown action '{action}'. Usage: .tool <enable|disable> <name>"),
|
||||
};
|
||||
|
||||
if self.session.is_none() && self.agent.is_some() {
|
||||
if self.agent.as_ref().is_some_and(|a| a.is_graph()) {
|
||||
bail!(
|
||||
"Cannot adjust tools for an agent outside a session; start a session first with `.session`"
|
||||
"Graph agents define tools per-node via `tools:` in graph.yaml; agent-level enabled_tools has no effect"
|
||||
);
|
||||
}
|
||||
|
||||
let pool = self.concrete_tool_names();
|
||||
let is_alias = self.app.config.mapping_tools.contains_key(name);
|
||||
let is_alias = self
|
||||
.app
|
||||
.config
|
||||
.mapping_tools
|
||||
.get(name)
|
||||
.is_some_and(|values| {
|
||||
values
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.any(|v| pool.iter().any(|p| p.as_str() == v))
|
||||
});
|
||||
if !pool.iter().any(|v| v == name) && !is_alias {
|
||||
bail!("Unknown tool '{name}'. Run `.list tools` to see what's available");
|
||||
}
|
||||
|
||||
let current: Option<Vec<String>> = if let Some(session) = &self.session {
|
||||
session.enabled_tools()
|
||||
} else if let Some(agent) = &self.agent {
|
||||
agent.enabled_tools()
|
||||
} else if let Some(role) = &self.role {
|
||||
role.enabled_tools()
|
||||
} else {
|
||||
@@ -1135,13 +1167,32 @@ impl RequestContext {
|
||||
list.push(name.to_string());
|
||||
list
|
||||
}
|
||||
None => vec![name.to_string()],
|
||||
None => {
|
||||
if self.agent.is_some() {
|
||||
println!(
|
||||
"Tool '{name}' is already enabled (no filter is set; the agent's full tool pool is active)."
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
vec![name.to_string()]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match current {
|
||||
None => {
|
||||
println!("No tools are enabled in this context; nothing to disable.");
|
||||
return Ok(());
|
||||
if self.agent.is_some() {
|
||||
let mut materialized = pool;
|
||||
materialized.retain(|v| v != name);
|
||||
println!(
|
||||
"Note: no filter was set; materialized the agent's pool into {} concrete tools.",
|
||||
materialized.len()
|
||||
);
|
||||
|
||||
materialized
|
||||
} else {
|
||||
println!("No tools are enabled in this context; nothing to disable.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Some(list) if list.is_empty() => {
|
||||
println!("No tools are enabled in this context; nothing to disable.");
|
||||
@@ -1154,6 +1205,7 @@ impl RequestContext {
|
||||
"Note: expanded 'all' into {} concrete tools.",
|
||||
materialized.len()
|
||||
);
|
||||
|
||||
materialized
|
||||
}
|
||||
Some(mut list) => {
|
||||
@@ -1470,6 +1522,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),
|
||||
@@ -1828,6 +1898,9 @@ impl RequestContext {
|
||||
|| (!matches!(agent.skills_enabled(), Some(false))
|
||||
&& v.name.starts_with(SKILL_FUNCTION_PREFIX))
|
||||
|| v.name.starts_with(USER_FUNCTION_PREFIX)
|
||||
|| v.name.starts_with(TODO_FUNCTION_PREFIX)
|
||||
|| v.name.starts_with(SUPERVISOR_FUNCTION_PREFIX)
|
||||
|| v.name.starts_with(MEMORY_FUNCTION_PREFIX)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2289,20 +2362,21 @@ impl RequestContext {
|
||||
Ok(())
|
||||
}
|
||||
"tools" => {
|
||||
let mut names: Vec<String> = self
|
||||
.tool_scope
|
||||
.functions
|
||||
.declarations()
|
||||
let mut names = self.concrete_tool_names();
|
||||
let aliases: Vec<String> = self
|
||||
.app
|
||||
.config
|
||||
.mapping_tools
|
||||
.iter()
|
||||
.filter(|v| {
|
||||
!v.name.starts_with("user__")
|
||||
&& !v.name.starts_with("mcp_")
|
||||
&& !v.name.starts_with("todo__")
|
||||
&& !v.name.starts_with("agent__")
|
||||
.filter(|(_, expansion)| {
|
||||
expansion
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.any(|v| names.iter().any(|p| p.as_str() == v))
|
||||
})
|
||||
.map(|v| v.name.clone())
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect();
|
||||
names.extend(self.app.config.mapping_tools.keys().map(|v| v.to_string()));
|
||||
names.extend(aliases);
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
|
||||
@@ -2462,6 +2536,11 @@ impl RequestContext {
|
||||
}
|
||||
}
|
||||
"enabled_tools" => {
|
||||
if self.agent.as_ref().is_some_and(|a| a.is_graph()) {
|
||||
bail!(
|
||||
"Graph agents define tools per-node via `tools:` in graph.yaml; agent-level enabled_tools has no effect"
|
||||
);
|
||||
}
|
||||
let raw: Option<String> = super::parse_value(value)?;
|
||||
let parsed: Option<Vec<String>> = raw.map(|s| super::csv_to_vec(&s));
|
||||
if !self.set_enabled_tools_on_role_like(parsed.clone()) {
|
||||
@@ -2900,20 +2979,36 @@ impl RequestContext {
|
||||
{
|
||||
let current = if let Some(session) = &self.session {
|
||||
session.enabled_tools()
|
||||
} else if let Some(agent) = &self.agent {
|
||||
agent.enabled_tools()
|
||||
} else if let Some(role) = &self.role {
|
||||
role.enabled_tools()
|
||||
} else {
|
||||
self.app.config.enabled_tools.clone()
|
||||
}
|
||||
.unwrap_or_default();
|
||||
let has_all = current.iter().any(|s| s.trim() == "all");
|
||||
};
|
||||
let agent_unfiltered = self.agent.is_some() && current.is_none();
|
||||
let current = current.unwrap_or_default();
|
||||
let has_all = agent_unfiltered || current.iter().any(|s| s.trim() == "all");
|
||||
|
||||
let candidates: Vec<String> = if args.first() == Some(&"enable") {
|
||||
if has_all {
|
||||
vec![]
|
||||
} else {
|
||||
let mut candidates = self.concrete_tool_names();
|
||||
candidates.extend(self.app.config.mapping_tools.keys().cloned());
|
||||
let pool = self.concrete_tool_names();
|
||||
let mut candidates = pool.clone();
|
||||
candidates.extend(
|
||||
self.app
|
||||
.config
|
||||
.mapping_tools
|
||||
.iter()
|
||||
.filter(|(_, expansion)| {
|
||||
expansion
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.any(|v| pool.iter().any(|p| p.as_str() == v))
|
||||
})
|
||||
.map(|(k, _)| k.clone()),
|
||||
);
|
||||
candidates.sort_unstable();
|
||||
candidates.dedup();
|
||||
candidates.retain(|v| !self.tool_list_covers(¤t, v));
|
||||
@@ -4721,6 +4816,55 @@ mod tests {
|
||||
assert!(names.contains(&"skill__unload"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn select_functions_preserves_infra_tools_under_agent_filter() {
|
||||
let _guard = TestConfigDirGuard::new();
|
||||
let mut ctx = create_test_ctx();
|
||||
let app = ctx.app.config.clone();
|
||||
let agent_name = format!(
|
||||
"test_infra_agent_{}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
let agent_dir = paths::agent_data_dir(&agent_name);
|
||||
create_dir_all(&agent_dir).unwrap();
|
||||
write(
|
||||
agent_dir.join("config.yaml"),
|
||||
format!(
|
||||
"name: {agent_name}\ninstructions: hi\nauto_continue: true\ncan_spawn_agents: true\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let abort = utils::create_abort_signal();
|
||||
run_async(ctx.use_agent(&app, &agent_name, None, abort)).unwrap();
|
||||
|
||||
let mut role = Role::new("r", "p");
|
||||
role.set_enabled_tools(Some(vec!["foo".to_string()]));
|
||||
|
||||
let fns = ctx.select_functions(&role).unwrap();
|
||||
let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect();
|
||||
assert!(
|
||||
names.contains(&"todo__init"),
|
||||
"todo__ tools must survive an agent tool filter, got: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"agent__spawn"),
|
||||
"agent__ tools must survive an agent tool filter, got: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"agent__send_message"),
|
||||
"teammate tools must survive an agent tool filter, got: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"user__ask"),
|
||||
"user__ tools must survive an agent tool filter, got: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_for_branch_clones_skill_registry() {
|
||||
let mut ctx = create_test_ctx();
|
||||
|
||||
+19
-33
@@ -474,7 +474,7 @@ fn rename_memory(store: &MemoryStore, cwd: &Path, args: &Value) -> Result<Value>
|
||||
let description = renamed.frontmatter.description.clone().unwrap_or_default();
|
||||
ensure_index_entry(&index_path, &new_name, &description)?;
|
||||
|
||||
// Other indexes (other scope's MEMORY.md, lite COYOTE.md): rewrite wikilinks only.
|
||||
// Other indexes (other scope's MEMORY.md): rewrite wikilinks only.
|
||||
for other_index in other_index_paths(store, &target_dir) {
|
||||
if let Ok(existing) = fs::read_to_string(&other_index)
|
||||
&& existing.contains(&needle)
|
||||
@@ -539,17 +539,11 @@ fn other_index_paths(store: &MemoryStore, own_dir: &Path) -> Vec<PathBuf> {
|
||||
out.push(global_index);
|
||||
}
|
||||
|
||||
match &store.workspace {
|
||||
Some(WorkspaceMemory::Structured { dir, .. }) => {
|
||||
let index = dir.join("MEMORY.md");
|
||||
if dir.as_path() != own_dir && index.exists() {
|
||||
out.push(index);
|
||||
}
|
||||
if let Some(ws) = &store.workspace {
|
||||
let index = ws.dir.join("MEMORY.md");
|
||||
if ws.dir.as_path() != own_dir && index.exists() {
|
||||
out.push(index);
|
||||
}
|
||||
Some(WorkspaceMemory::Lite { file, .. }) if file.exists() => {
|
||||
out.push(file.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
out
|
||||
@@ -637,10 +631,7 @@ fn find_file(store: &MemoryStore, name: &str) -> Result<Option<MemoryFile>> {
|
||||
|
||||
fn workspace_write_dir(store: &MemoryStore, cwd: &Path) -> Result<PathBuf> {
|
||||
match &store.workspace {
|
||||
Some(WorkspaceMemory::Structured { dir, .. }) => Ok(dir.clone()),
|
||||
Some(WorkspaceMemory::Lite { workspace_root, .. }) => {
|
||||
Ok(paths::workspace_memory_dir_for(workspace_root))
|
||||
}
|
||||
Some(ws) => Ok(ws.dir.clone()),
|
||||
None => match find_git_root(cwd) {
|
||||
Some(git_root) => bootstrap_workspace_memory(&git_root),
|
||||
None => bail!(
|
||||
@@ -652,20 +643,10 @@ fn workspace_write_dir(store: &MemoryStore, cwd: &Path) -> Result<PathBuf> {
|
||||
}
|
||||
|
||||
fn workspace_label(w: &WorkspaceMemory) -> Value {
|
||||
match w {
|
||||
WorkspaceMemory::Structured { workspace_root, .. } => json!({
|
||||
"mode": "structured",
|
||||
"root": workspace_root.display().to_string(),
|
||||
}),
|
||||
WorkspaceMemory::Lite {
|
||||
workspace_root,
|
||||
file,
|
||||
} => json!({
|
||||
"mode": "lite",
|
||||
"root": workspace_root.display().to_string(),
|
||||
"file": file.display().to_string(),
|
||||
}),
|
||||
}
|
||||
json!({
|
||||
"root": w.workspace_root.display().to_string(),
|
||||
"dir": w.dir.display().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn lint_memory(store: &MemoryStore) -> Result<Value> {
|
||||
@@ -872,19 +853,24 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_write_dir_promotes_lite_to_structured_subdir() {
|
||||
let root = temp_root("ws_lite_promote");
|
||||
fn workspace_write_dir_treats_root_instructions_file_as_no_memory() {
|
||||
let root = temp_root("ws_instructions_only");
|
||||
let workspace = root.join("ws");
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
fs::write(workspace.join("COYOTE.md"), "lite").unwrap();
|
||||
fs::create_dir_all(workspace.join(".git")).unwrap();
|
||||
fs::write(workspace.join("COYOTE.md"), "instructions, not memory").unwrap();
|
||||
|
||||
let store = MemoryStore {
|
||||
global_dir: root.join("g"),
|
||||
workspace: discover_workspace_memory(&workspace),
|
||||
};
|
||||
assert!(store.workspace.is_none(), "COYOTE.md must not be memory");
|
||||
|
||||
let dir = workspace_write_dir(&store, &workspace).unwrap();
|
||||
assert_eq!(dir, workspace.join(".coyote").join("memory"));
|
||||
assert!(
|
||||
dir.join("MEMORY.md").exists(),
|
||||
"bootstrap must create index"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
+44
-5
@@ -21,12 +21,13 @@ use crate::cli::Cli;
|
||||
use crate::client::{
|
||||
ModelType, call_chat_completions, call_chat_completions_streaming, list_models, oauth,
|
||||
};
|
||||
use crate::config::paths;
|
||||
use crate::config::instructions::WORKSPACE_INSTRUCTIONS_FILE_NAME;
|
||||
use crate::config::{
|
||||
Agent, AppConfig, AppState, CODE_ROLE, Config, EXPLAIN_SHELL_ROLE, Input, MemoryScope,
|
||||
RequestContext, SHELL_ROLE, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists,
|
||||
install_builtins, list_agents, load_env_file, macro_execute, sync_models,
|
||||
};
|
||||
use crate::config::{memory, paths};
|
||||
use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail};
|
||||
use crate::mcp::McpServersConfig;
|
||||
use crate::render::{prompt_theme, render_error};
|
||||
@@ -369,6 +370,15 @@ async fn run(
|
||||
if cli.no_memory {
|
||||
update_app_config(&mut ctx, |app| app.memory = Some(false));
|
||||
}
|
||||
if cli.no_workspace_instructions {
|
||||
update_app_config(&mut ctx, |app| app.workspace_instructions = Some(false));
|
||||
}
|
||||
if !cli.workspace_instructions_file.is_empty() {
|
||||
let files = cli.workspace_instructions_file.clone();
|
||||
update_app_config(&mut ctx, |app| {
|
||||
app.workspace_instructions_files = Some(files);
|
||||
});
|
||||
}
|
||||
if cli.empty_session {
|
||||
ctx.empty_session()?;
|
||||
}
|
||||
@@ -381,10 +391,14 @@ async fn run(
|
||||
paths::global_memory_index_path(),
|
||||
"# Global Memory\n\n<!-- Universal facts about you go here. The LLM uses this as always-on context. -->\n<!-- Drill files (when created) are listed below. -->\n",
|
||||
),
|
||||
MemoryScope::Workspace => (
|
||||
env::current_dir()?.join("COYOTE.md"),
|
||||
"# Workspace Memory\n\n<!-- Facts about this project go here. The LLM uses this as always-on context. -->\n",
|
||||
),
|
||||
MemoryScope::Workspace => {
|
||||
let cwd = env::current_dir()?;
|
||||
let root = memory::find_git_root(&cwd).unwrap_or(cwd);
|
||||
(
|
||||
paths::workspace_memory_index_path_for(&root),
|
||||
"# Workspace Memory Index\n\n<!-- Facts about this project go here. The LLM uses this as always-on context. -->\n<!-- Drill files (when created) are listed below. -->\n",
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
if path.exists() {
|
||||
@@ -397,9 +411,34 @@ async fn run(
|
||||
}
|
||||
|
||||
fs::write(&path, content)?;
|
||||
if scope == MemoryScope::Workspace
|
||||
&& let Some(git_root) = memory::find_git_root(&path)
|
||||
{
|
||||
memory::append_gitignore_entry(&git_root)?;
|
||||
}
|
||||
println!("✓ Created memory marker at '{}'.", path.display());
|
||||
return Ok(());
|
||||
}
|
||||
if cli.init_instructions {
|
||||
let path = env::current_dir()?.join(WORKSPACE_INSTRUCTIONS_FILE_NAME);
|
||||
|
||||
if path.exists() {
|
||||
eprintln!(
|
||||
"Workspace instructions already exist at '{}'.",
|
||||
path.display()
|
||||
);
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
fs::write(
|
||||
&path,
|
||||
"# Project Instructions\n\n<!-- Human-curated instructions for AI agents working in this repo. -->\n<!-- Coyote injects this file into the system prompt read-only, in full. -->\n",
|
||||
)?;
|
||||
println!("✓ Created workspace instructions at '{}'.", path.display());
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
if cli.info {
|
||||
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
|
||||
let info = ctx.info(app.as_ref())?;
|
||||
|
||||
@@ -947,6 +947,46 @@ pub async fn run_repl_command(
|
||||
let app = Arc::clone(&ctx.app.config);
|
||||
ctx.use_agent(app.as_ref(), agent_name, session_name, abort_signal.clone())
|
||||
.await?;
|
||||
if let Some(session) = &ctx.session {
|
||||
let messages_snapshot: Vec<Message> = session
|
||||
.messages()
|
||||
.iter()
|
||||
.filter(|m| !m.role.is_system())
|
||||
.cloned()
|
||||
.collect();
|
||||
let compressed_count = session.compressed_messages().len();
|
||||
if !messages_snapshot.is_empty() || compressed_count > 0 {
|
||||
if compressed_count > 0 {
|
||||
println!(
|
||||
"{}",
|
||||
dimmed_text(&format!(
|
||||
"({compressed_count} earlier messages not shown — compressed for context)"
|
||||
))
|
||||
);
|
||||
println!();
|
||||
}
|
||||
for message in &messages_snapshot {
|
||||
match message.role {
|
||||
MessageRole::User => {
|
||||
if let Some(text) = message.content.as_text() {
|
||||
println!("{}", dimmed_text("You:"));
|
||||
println!("{text}");
|
||||
println!();
|
||||
}
|
||||
}
|
||||
MessageRole::Assistant => {
|
||||
if let Some(text) = message.content.as_text() {
|
||||
app.print_markdown(text)?;
|
||||
println!();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
println!("{}", dimmed_text("─── ↑ previous conversation ↑ ───"));
|
||||
println!();
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
println!(r#"Usage: .agent <agent-name> [session-name] [key=value]..."#)
|
||||
|
||||
Reference in New Issue
Block a user