Files
codecraft/server/src/prompts.rs
T

433 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Agents (modes), system prompts, project instructions, slash commands and skills.
//!
//! Compatible with Claude Code / opencode layouts: AGENTS.md, CLAUDE.md,
//! .claude/{agents,commands,skills}, .codecraft/{agents,commands,skills}.
use crate::app::App;
use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str};
use anyhow::Result;
use async_trait::async_trait;
use serde::Serialize;
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize)]
pub struct Agent {
pub name: String,
pub description: String,
/// "primary" (selectable mode) or "subagent" (task tool only).
pub mode: String,
#[serde(skip)]
pub prompt: String,
/// Allowed tools; None = all.
pub tools: Option<Vec<String>>,
pub read_only: bool,
/// "coding" or "chat" (plain conversation prompt).
pub style: String,
pub model: Option<String>,
/// "builtin", "config" or "file".
pub source: String,
/// Max. instances running at the same time.
pub max_parallel: Option<usize>,
/// Review loop after each turn (None/0 rounds = off).
pub refine: Option<crate::config::RefineConfig>,
/// Source file of Markdown agents.
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
pub use codecraft_core::prompts::*;
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
let craft = craft_prompt(clean_room);
vec![
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "chat".into(), description: "Just talk: answers, explanations, web search – no files or shell".into(), mode: "primary".into(), prompt: CHAT_PROMPT.into(), tools: Some(vec!["web_search".into(), "web_fetch".into()]), read_only: true, style: "chat".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
]
}
/// Parse "---\nkey: value\n---\nbody" frontmatter (flat keys only).
pub fn frontmatter(text: &str) -> (BTreeMap<String, String>, String) {
let mut meta = BTreeMap::new();
let t = text.trim_start_matches('\u{feff}');
if let Some(rest) = t.strip_prefix("---") {
if let Some(end) = rest.find("\n---") {
for line in rest[..end].lines() {
if let Some((k, v)) = line.split_once(':') {
meta.insert(k.trim().to_string(), v.trim().trim_matches('"').trim_matches('\'').to_string());
}
}
let body = rest[end + 4..].trim_start_matches(['-', '\n', '\r']).to_string();
return (meta, body);
}
}
(meta, t.to_string())
}
/// Resource directories, lowest priority first: data dir, workspace, project.
/// Skills and commands shipped with CodeCraft (read-only for agents).
pub fn builtin_resources() -> PathBuf {
if let Ok(p) = std::env::var("CODECRAFT_RESOURCES") {
return PathBuf::from(p);
}
["/app/resources", "./resources", "./server/resources"].iter().map(PathBuf::from).find(|p| p.is_dir()).unwrap_or_else(|| PathBuf::from("/app/resources"))
}
/// Resource directories, lowest priority first: built-in, data dir, workspace, project.
fn resource_dirs(app: &App, root: &Path, cwd: &Path, kind: &str) -> Vec<PathBuf> {
let mut dirs = vec![builtin_resources().join(kind), app.data_dir().join(kind)];
let ws = root.to_path_buf();
let mut chain = vec![ws.clone()];
if cwd != ws {
chain.push(cwd.to_path_buf());
}
for base in chain {
dirs.push(base.join(".claude").join(kind));
dirs.push(base.join(".codecraft").join(kind));
}
dirs
}
pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
let cfg = app.config();
let mut map: BTreeMap<String, Agent> =
builtin_agents(cfg.craft.clean_room).into_iter().map(|a| (a.name.clone(), a)).collect();
// Markdown agents (Claude Code format).
for dir in resource_dirs(app, root, cwd, "agents") {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for e in entries.flatten() {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) != Some("md") {
continue;
}
let Ok(text) = std::fs::read_to_string(&p) else { continue };
let (meta, body) = frontmatter(&text);
let name = meta.get("name").cloned().unwrap_or_else(|| p.file_stem().unwrap().to_string_lossy().into());
let tools = meta.get("tools").map(|t| t.split(',').map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty()).collect());
map.insert(name.clone(), Agent {
name,
description: meta.get("description").cloned().unwrap_or_default(),
mode: meta.get("mode").cloned().unwrap_or_else(|| "subagent".into()),
prompt: body,
tools,
read_only: meta.get("read_only").is_some_and(|v| v == "true"),
style: meta.get("style").filter(|v| *v == "chat").cloned().unwrap_or_else(|| "coding".into()),
model: meta.get("model").filter(|m| m.contains('/')).cloned(),
source: "file".into(),
max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()),
refine: meta.get("refine_rounds").and_then(|v| v.parse().ok()).map(|rounds| crate::config::RefineConfig { rounds, model: meta.get("refine_model").cloned(), criteria: None }),
path: Some(p.display().to_string()),
});
}
}
// Config agents override everything.
for (name, a) in &cfg.agents {
let base = map.get(name).cloned();
map.insert(name.clone(), Agent {
name: name.clone(),
description: a.description.clone().or(base.as_ref().map(|b| b.description.clone())).unwrap_or_default(),
mode: a.mode.clone().or(base.as_ref().map(|b| b.mode.clone())).unwrap_or_else(|| "subagent".into()),
prompt: a.prompt.clone().or(base.as_ref().map(|b| b.prompt.clone())).unwrap_or_default(),
tools: a.tools.clone().or(base.as_ref().and_then(|b| b.tools.clone())),
read_only: a.read_only.unwrap_or_else(|| base.as_ref().is_some_and(|b| b.read_only)),
style: a.style.clone().filter(|s| s == "chat" || s == "coding").or(base.as_ref().map(|b| b.style.clone())).unwrap_or_else(|| "coding".into()),
model: a.model.clone().filter(|m| !m.is_empty()).or(base.as_ref().and_then(|b| b.model.clone())),
source: "config".into(),
max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)),
refine: a.refine.clone().or(base.as_ref().and_then(|b| b.refine.clone())),
path: None,
});
}
map.into_values().collect()
}
/// Personal modes a user created for themselves (user setting "modes").
pub fn personal_modes(app: &App, user_id: &str) -> BTreeMap<String, crate::config::AgentConfig> {
app.store.user_setting(user_id, "modes").unwrap_or_default()
}
/// All agents plus the user's personal modes. Personal modes never replace built-in,
/// admin or project ones of the same name.
pub fn agents_for(app: &App, root: &Path, cwd: &Path, user_id: Option<&str>) -> Vec<Agent> {
let mut list = agents(app, root, cwd);
let Some(uid) = user_id else { return list };
for (name, a) in personal_modes(app, uid) {
if list.iter().any(|x| x.name == name) {
continue;
}
list.push(Agent {
name,
description: a.description.unwrap_or_default(),
mode: "primary".into(),
prompt: a.prompt.unwrap_or_default(),
tools: a.tools,
read_only: a.read_only.unwrap_or(false),
style: a.style.filter(|s| s == "chat").unwrap_or_else(|| "coding".into()),
model: a.model.filter(|m| !m.is_empty()),
source: "personal".into(),
max_parallel: None,
refine: a.refine,
path: None,
});
}
list
}
pub fn find_agent_for(app: &App, root: &Path, cwd: &Path, name: &str, user_id: Option<&str>) -> Agent {
agents_for(app, root, cwd, user_id)
.into_iter()
.find(|a| a.name == name)
.unwrap_or_else(|| builtin_agents(true).remove(0))
}
/// AGENTS.md / CLAUDE.md files from the data dir, workspace root down to cwd.
fn instruction_files(app: &App, root: &Path, cwd: &Path) -> Vec<(PathBuf, String)> {
let mut dirs = vec![app.data_dir()];
let ws = root.to_path_buf();
let mut d = cwd.to_path_buf();
let mut chain = Vec::new();
loop {
chain.push(d.clone());
if d == ws || !d.starts_with(&ws) || !d.pop() {
break;
}
}
chain.reverse();
dirs.extend(chain);
let mut out = Vec::new();
for dir in dirs {
for name in ["AGENTS.md", "CLAUDE.md", ".claude/CLAUDE.md"] {
let p = dir.join(name);
if let Ok(text) = std::fs::read_to_string(&p) {
if !text.trim().is_empty() {
out.push((p, text));
}
}
}
}
out
}
/// The plain chat mode: no coding instructions, project files or memory – only the
/// date, the model and what the user keeps in their personal memory.
/// Add the instructions the user set for this chat (they come last, so they win).
pub fn with_chat_instructions(mut system: String, instructions: &str) -> String {
if !instructions.trim().is_empty() {
system.push_str(&format!(
"\n\n# Instructions for this chat\nThe user set these for this chat. They apply to every message and override the defaults above:\n{}\n",
instructions.trim()
));
}
system
}
fn chat_prompt(app: &App, agent: &Agent, model: &str, owner: Option<&crate::store::User>) -> String {
let mut s = agent.prompt.clone();
s.push_str(&format!("\n\n# Context\n- Date: {}\n- Model: {model}\n", chrono::Local::now().format("%A, %Y-%m-%d")));
if let Some(u) = owner {
let memory: String = app.store.user_setting(&u.id, "memory").unwrap_or_default();
if !memory.trim().is_empty() {
s.push_str(&format!("\n# About the user (personal memory)\n{}\n", memory.trim()));
}
}
s
}
pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &str, permission_mode: &str, owner: Option<&crate::store::User>) -> String {
if agent.style == "chat" {
return chat_prompt(app, agent, model, owner);
}
let mut s = String::from(BASE_PROMPT);
s.push_str("\n\n");
s.push_str(&agent.prompt);
if std::env::var("HOME").is_ok_and(|h| h.starts_with("/data")) {
s.push_str("\n\n# Installing tools\nYou run without root (no apt/sudo). Install what you need in user space – it persists: Python packages with `pip install <pkg>` (user site) or a venv, CLI tools with `pipx install`, Rust with `curl https://sh.rustup.rs -sSf | sh -s -- -y` (then `~/.cargo/bin` is on PATH), Node packages with `npm i -g`, and other binaries (e.g. Godot) by downloading them to `~/.local/bin`. Check with `which`/`--version` first whether a tool is already installed.");
}
if permission_mode == "auto" && !agent.read_only {
s.push_str(AUTO_MODE_PROMPT);
}
let git = cwd.join(".git").exists();
s.push_str(&format!(
"\n\n# Environment\n- Working directory: {}\n- Project root: {}\n- Git repository: {}\n- Platform: {} ({})\n- Model: {model}\n- Date: {}\n",
cwd.display(),
root.display(),
if git { "yes" } else { "no" },
std::env::consts::OS,
std::env::consts::ARCH,
chrono::Local::now().format("%Y-%m-%d"),
));
if !agent.read_only || agent.name == "plan" {
let subs: Vec<Agent> = agents(app, root, cwd).into_iter().filter(|a| a.mode != "primary").collect();
if !subs.is_empty() {
s.push_str("\n# Subagents\nAvailable for the task tool (subagent_type). Delegate work that fits a subagent's description – especially writing, summarizing and translating – and use background=true for tasks whose result you do not need right away.\n");
for a in &subs {
let model = a.model.as_deref().map(|m| format!(" [model: {m}]")).unwrap_or_default();
let ro = if a.read_only { " (read-only)" } else { "" };
s.push_str(&format!("- {}{ro}{model}: {}\n", a.name, a.description));
}
}
}
let deferred = app.mcp.deferred_summary(&app.config(), owner);
if !deferred.is_empty() {
s.push_str("\n# On-demand MCP servers\nThese servers have many tools that are not listed directly. Find tools with mcp_search, then call them with mcp_call:\n");
for (server, n, names) in &deferred {
s.push_str(&format!("- {server} ({n} tools, e.g. {})\n", names.join(", ")));
}
}
let skills = skills(app, root, cwd);
if !skills.is_empty() {
s.push_str("\n# Skills\nLoad a skill with the skill tool when the task matches its description:\n");
for sk in &skills {
s.push_str(&format!("- {}: {}\n", sk.name, sk.description));
}
}
if let Some(u) = owner {
let memory: String = app.store.user_setting(&u.id, "memory").unwrap_or_default();
if !memory.trim().is_empty() {
s.push_str(&format!("\n# About the user (personal memory)\n{}\n", memory.trim()));
}
}
match std::fs::read_to_string(cwd.join(".codecraft").join("memory.md")) {
Ok(mem) => s.push_str(&format!("\n# Project memory\nFacts learned earlier in this project (keep it current with the remember tool):\n{}\n", mem.chars().take(20_000).collect::<String>())),
Err(_) if !agent.read_only => s.push_str("\n# Project memory\nWhen you learn something lasting about this project (how to build/test, conventions, pitfalls), save it with the remember tool.\n"),
Err(_) => {}
}
let files = instruction_files(app, root, cwd);
if !files.is_empty() {
s.push_str("\n# Project instructions\nThese instructions come from the user's instruction files and OVERRIDE default behaviour.\n");
for (p, text) in files {
s.push_str(&format!("\n<instructions path=\"{}\">\n{}\n</instructions>\n", p.display(), text.trim()));
}
}
s
}
// ---------------------------------------------------------------------------
// Slash commands
#[derive(Debug, Clone, Serialize)]
pub struct Command {
pub name: String,
pub description: String,
#[serde(skip)]
pub body: String,
pub agent: Option<String>,
/// Craft: switch the session to clean room (true) or deep (false).
#[serde(skip)]
pub clean_room: Option<bool>,
}
pub fn commands(app: &App, root: &Path, cwd: &Path) -> Vec<Command> {
let mut map = BTreeMap::new();
for dir in resource_dirs(app, root, cwd, "commands") {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for e in entries.flatten() {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) != Some("md") {
continue;
}
let Ok(text) = std::fs::read_to_string(&p) else { continue };
let (meta, body) = frontmatter(&text);
let name = p.file_stem().unwrap().to_string_lossy().to_string();
let description = meta
.get("description")
.cloned()
.unwrap_or_else(|| body.lines().find(|l| !l.trim().is_empty()).unwrap_or("").chars().take(80).collect());
let clean_room = meta.get("clean_room").and_then(|v| v.parse::<bool>().ok());
map.insert(name.clone(), Command { name, description, body, agent: meta.get("agent").cloned(), clean_room });
}
}
map.into_values().collect()
}
/// Expand "/name args" into the command's prompt. Returns None if not a known command.
pub fn expand_command(app: &App, root: &Path, cwd: &Path, text: &str) -> Option<(String, Option<String>, Option<bool>)> {
let rest = text.strip_prefix('/')?;
let (name, args) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
let cmd = commands(app, root, cwd).into_iter().find(|c| c.name == name)?;
let args = args.trim();
let mut body = cmd.body.replace("$ARGUMENTS", args);
for (i, a) in args.split_whitespace().enumerate() {
body = body.replace(&format!("${}", i + 1), a);
}
if !cmd.body.contains("$ARGUMENTS") && !args.is_empty() {
body.push_str(&format!("\n\n{args}"));
}
Some((body, cmd.agent, cmd.clean_room))
}
// ---------------------------------------------------------------------------
// Skills
#[derive(Debug, Clone, Serialize)]
pub struct Skill {
pub name: String,
pub description: String,
pub path: PathBuf,
}
pub fn skills(app: &App, root: &Path, cwd: &Path) -> Vec<Skill> {
let mut map = BTreeMap::new();
for dir in resource_dirs(app, root, cwd, "skills") {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for e in entries.flatten() {
let p = e.path().join("SKILL.md");
let Ok(text) = std::fs::read_to_string(&p) else { continue };
let (meta, _) = frontmatter(&text);
let name = meta.get("name").cloned().unwrap_or_else(|| e.file_name().to_string_lossy().into());
map.insert(name.clone(), Skill { name, description: meta.get("description").cloned().unwrap_or_default(), path: p });
}
}
map.into_values().collect()
}
pub struct SkillTool;
#[async_trait]
impl Tool for SkillTool {
fn name(&self) -> &str {
"skill"
}
fn description(&self) -> String {
"Load a skill (packaged instructions for a kind of task) by name. Follow the returned instructions.".into()
}
fn schema(&self) -> Value {
json!({"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]})
}
fn read_only(&self) -> bool {
true
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let name = arg_str(&input, "name")?;
let Some(sk) = skills(&ctx.app, &ctx.root(), &ctx.cwd).into_iter().find(|s| s.name == name) else {
return Ok(ToolOutput::error(format!("unknown skill '{name}'")));
};
let text = std::fs::read_to_string(&sk.path)?;
let (_, body) = frontmatter(&text);
let dir = sk.path.parent().unwrap_or(Path::new("."));
Ok(ToolOutput::text(format!("Skill '{name}' (base directory: {}):\n\n{body}", dir.display())))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_frontmatter() {
let (m, b) = frontmatter("---\nname: x\ndescription: \"does y\"\n---\nBody here\n");
assert_eq!(m["name"], "x");
assert_eq!(m["description"], "does y");
assert_eq!(b.trim(), "Body here");
let (m2, b2) = frontmatter("no frontmatter");
assert!(m2.is_empty());
assert_eq!(b2, "no frontmatter");
}
}