Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/cortex-app-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,4 @@ gethostname = "0.5"

[dev-dependencies]
tokio-test = { workspace = true }
tempfile = { workspace = true }
190 changes: 120 additions & 70 deletions src/cortex-app-server/src/api/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,54 @@ use super::types::{
ImportAgentRequest, UpdateAgentRequest,
};

const PROJECT_AGENTS_DIR: &str = ".cortex/agents";
const LEGACY_PROJECT_AGENTS_DIR: &str = ".factory/agents";

fn project_agents_dir() -> std::path::PathBuf {
std::path::PathBuf::from(PROJECT_AGENTS_DIR)
}

fn legacy_project_agents_dir() -> std::path::PathBuf {
std::path::PathBuf::from(LEGACY_PROJECT_AGENTS_DIR)
}

fn user_agents_dir() -> Option<std::path::PathBuf> {
dirs::home_dir().map(|home| home.join(".cortex/agents"))
}

fn legacy_user_agents_dir() -> Option<std::path::PathBuf> {
dirs::home_dir().map(|home| home.join(".factory/agents"))
}

fn writable_agents_dir(scope: &str) -> AppResult<std::path::PathBuf> {
if scope == "project" {
Ok(project_agents_dir())
} else {
user_agents_dir()
.ok_or_else(|| AppError::Internal("Cannot find home directory".to_string()))
}
}

fn search_agent_dirs() -> Vec<(std::path::PathBuf, &'static str)> {
let mut dirs = vec![
(project_agents_dir(), "project"),
(legacy_project_agents_dir(), "project"),
];

if let Some(user_dir) = user_agents_dir() {
dirs.push((user_dir, "user"));
}
if let Some(user_dir) = legacy_user_agents_dir() {
dirs.push((user_dir, "user"));
}

dirs
}

fn agent_path_in(dir: &std::path::Path, name: &str) -> std::path::PathBuf {
dir.join(format!("{}.md", name))
}

/// Read agent file from disk.
fn read_agent_file(path: &std::path::Path, scope: &str) -> Option<AgentDefinition> {
if path.extension().and_then(|e| e.to_str()) != Some("md") {
Expand Down Expand Up @@ -73,28 +121,17 @@ fn read_agent_file(path: &std::path::Path, scope: &str) -> Option<AgentDefinitio
/// List all agents.
pub async fn list_agents() -> AppResult<Json<Vec<AgentDefinition>>> {
let mut agents = Vec::new();
let mut seen_names = std::collections::HashSet::new();

// Project agents (.factory/agents/)
let project_dir = std::path::Path::new(".factory/agents");
if project_dir.exists()
&& let Ok(entries) = std::fs::read_dir(project_dir)
{
for entry in entries.flatten() {
if let Some(agent) = read_agent_file(&entry.path(), "project") {
agents.push(agent);
}
}
}

// User agents (~/.factory/agents/)
if let Some(home) = dirs::home_dir() {
let user_dir = home.join(".factory/agents");
if user_dir.exists()
&& let Ok(entries) = std::fs::read_dir(&user_dir)
for (dir, scope) in search_agent_dirs() {
if dir.exists()
&& let Ok(entries) = std::fs::read_dir(&dir)
{
for entry in entries.flatten() {
if let Some(agent) = read_agent_file(&entry.path(), "user") {
agents.push(agent);
if let Some(agent) = read_agent_file(&entry.path(), scope) {
if seen_names.insert(agent.name.clone()) {
agents.push(agent);
}
}
}
}
Expand All @@ -105,16 +142,9 @@ pub async fn list_agents() -> AppResult<Json<Vec<AgentDefinition>>> {

/// Get a specific agent.
pub async fn get_agent(Path(name): Path<String>) -> AppResult<Json<AgentDefinition>> {
// Check project first
let project_path = std::path::Path::new(".factory/agents").join(format!("{}.md", name));
if let Some(agent) = read_agent_file(&project_path, "project") {
return Ok(Json(agent));
}

// Check user
if let Some(home) = dirs::home_dir() {
let user_path = home.join(".factory/agents").join(format!("{}.md", name));
if let Some(agent) = read_agent_file(&user_path, "user") {
for (dir, scope) in search_agent_dirs() {
let path = agent_path_in(&dir, &name);
if let Some(agent) = read_agent_file(&path, scope) {
return Ok(Json(agent));
}
}
Expand All @@ -124,13 +154,7 @@ pub async fn get_agent(Path(name): Path<String>) -> AppResult<Json<AgentDefiniti

/// Create or update an agent.
pub async fn create_agent(Json(req): Json<CreateAgentRequest>) -> AppResult<Json<AgentDefinition>> {
let dir = if req.scope == "project" {
std::path::PathBuf::from(".factory/agents")
} else {
dirs::home_dir()
.ok_or_else(|| AppError::Internal("Cannot find home directory".to_string()))?
.join(".factory/agents")
};
let dir = writable_agents_dir(&req.scope)?;

std::fs::create_dir_all(&dir)
.map_err(|e| AppError::Internal(format!("Failed to create directory: {}", e)))?;
Expand Down Expand Up @@ -169,19 +193,10 @@ pub async fn create_agent(Json(req): Json<CreateAgentRequest>) -> AppResult<Json

/// Delete an agent.
pub async fn delete_agent(Path(name): Path<String>) -> AppResult<Json<serde_json::Value>> {
// Try project first
let project_path = std::path::Path::new(".factory/agents").join(format!("{}.md", name));
if project_path.exists() {
std::fs::remove_file(&project_path)
.map_err(|e| AppError::Internal(format!("Failed to delete: {}", e)))?;
return Ok(Json(serde_json::json!({"deleted": true})));
}

// Try user
if let Some(home) = dirs::home_dir() {
let user_path = home.join(".factory/agents").join(format!("{}.md", name));
if user_path.exists() {
std::fs::remove_file(&user_path)
for (dir, _scope) in search_agent_dirs() {
let path = agent_path_in(&dir, &name);
if path.exists() {
std::fs::remove_file(&path)
.map_err(|e| AppError::Internal(format!("Failed to delete: {}", e)))?;
return Ok(Json(serde_json::json!({"deleted": true})));
}
Expand Down Expand Up @@ -229,21 +244,16 @@ pub async fn update_agent(
Json(req): Json<UpdateAgentRequest>,
) -> AppResult<Json<AgentDefinition>> {
// Find existing agent
let project_path = std::path::Path::new(".factory/agents").join(format!("{}.md", name));
let user_path =
dirs::home_dir().map(|h| h.join(".factory/agents").join(format!("{}.md", name)));

let (existing, path) = if let Some(agent) = read_agent_file(&project_path, "project") {
(agent, project_path)
} else if let Some(ref user_path) = user_path {
if let Some(agent) = read_agent_file(user_path, "user") {
(agent, user_path.clone())
} else {
return Err(AppError::NotFound(format!("Agent not found: {}", name)));
let mut found = None;
for (dir, scope) in search_agent_dirs() {
let path = agent_path_in(&dir, &name);
if let Some(agent) = read_agent_file(&path, scope) {
found = Some((agent, path));
break;
}
} else {
return Err(AppError::NotFound(format!("Agent not found: {}", name)));
};
}
let (existing, path) =
found.ok_or_else(|| AppError::NotFound(format!("Agent not found: {}", name)))?;

// Merge updates
let updated = AgentDefinition {
Expand Down Expand Up @@ -352,13 +362,7 @@ pub async fn import_agent(Json(req): Json<ImportAgentRequest>) -> AppResult<Json
let (name, agent) = parse_agent_content(&req.content, &req.format)?;

// Determine directory
let dir = if req.scope == "project" {
std::path::PathBuf::from(".factory/agents")
} else {
dirs::home_dir()
.ok_or_else(|| AppError::Internal("Cannot find home directory".to_string()))?
.join(".factory/agents")
};
let dir = writable_agents_dir(&req.scope)?;

std::fs::create_dir_all(&dir)
.map_err(|e| AppError::Internal(format!("Failed to create directory: {}", e)))?;
Expand Down Expand Up @@ -409,3 +413,49 @@ pub async fn generate_agent_prompt(
permission_mode: "default".to_string(),
}))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_writable_project_agents_dir_uses_standard_loader_path() {
assert_eq!(
writable_agents_dir("project").unwrap(),
std::path::PathBuf::from(".cortex/agents")
);
}

#[test]
fn test_search_agent_dirs_include_standard_before_legacy_project_path() {
let dirs = search_agent_dirs();

assert_eq!(
dirs[0],
(std::path::PathBuf::from(".cortex/agents"), "project")
);
assert_eq!(
dirs[1],
(std::path::PathBuf::from(".factory/agents"), "project")
);
}

#[test]
fn test_read_agent_file_reads_standard_markdown_agent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("reviewer.md");
std::fs::write(
&path,
"---\ndescription: Code reviewer\ntools: [\"Read\", \"Grep\"]\nmodel: inherit\npermissionMode: default\n---\n\nReview code carefully.",
)
.unwrap();

let agent = read_agent_file(&path, "project").expect("agent should parse");

assert_eq!(agent.name, "reviewer");
assert_eq!(agent.description, "Code reviewer");
assert_eq!(agent.tools, vec!["Read", "Grep"]);
assert_eq!(agent.scope, "project");
assert_eq!(agent.prompt, "Review code carefully.");
}
}
34 changes: 18 additions & 16 deletions src/cortex-engine/src/tools/handlers/create_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ impl ToolHandler for CreateAgentHandler {

// Use context.cwd for project location to avoid relying on process cwd
let agents_dir = Self::get_agents_dir_with_cwd(location, &context.cwd)?;
let agent_path = agents_dir.join(format!("{}.toml", agent_name));
let agent_path = agents_dir.join(format!("{}.md", agent_name));

if agent_path.exists() {
return Err(CortexError::InvalidInput(format!(
Expand All @@ -139,18 +139,10 @@ impl ToolHandler for CreateAgentHandler {

std::fs::create_dir_all(&agents_dir)?;

let description_yaml =
serde_json::to_string(description).unwrap_or_else(|_| format!("{description:?}"));
let agent_content = format!(
r#"[agent]
name = "{}"
description = """
{}
"""

# Add custom configuration here
# [config]
# key = "value"
"#,
agent_name, description
"---\nname: {agent_name}\ndescription: {description_yaml}\nmodel: inherit\n---\n\n{description}\n"
);

std::fs::write(&agent_path, agent_content)?;
Expand Down Expand Up @@ -211,16 +203,26 @@ mod tests {
let expected_name = CreateAgentHandler::sanitize_filename(description);
let agent_path = temp_dir
.path()
.join(format!(".cortex/agents/{}.toml", expected_name));
.join(format!(".cortex/agents/{}.md", expected_name));
assert!(
agent_path.exists(),
"Agent file should exist at {:?}",
agent_path
);

let content = std::fs::read_to_string(&agent_path).unwrap();
assert!(content.contains("name = \"process-and-transform-data-files\""));
assert!(content.contains("name: process-and-transform-data-files"));
assert!(content.contains("model: inherit"));
assert!(content.contains("Process and transform data files"));

let loaded = cortex_agents_ext::custom::loader::sync::load_from_dir(
&temp_dir.path().join(".cortex/agents"),
)
.expect("standard loader should read created agent");
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].name, "process-and-transform-data-files");
assert_eq!(loaded[0].description, "Process and transform data files");
assert_eq!(loaded[0].model, "inherit");
}

#[tokio::test]
Expand Down Expand Up @@ -325,7 +327,7 @@ mod tests {
let expected_name = CreateAgentHandler::sanitize_filename(description);
let agent_path = temp_dir
.path()
.join(format!(".cortex/agents/{}.toml", expected_name));
.join(format!(".cortex/agents/{}.md", expected_name));
assert!(
agent_path.exists(),
"Agent file should exist at {:?}",
Expand All @@ -351,7 +353,7 @@ mod tests {

let agent_path = temp_dir
.path()
.join(".cortex/agents/duplicate-agent-test-case.toml");
.join(".cortex/agents/duplicate-agent-test-case.md");
assert!(
agent_path.exists(),
"First agent file should exist at {:?}",
Expand Down