Files
codecraft/server/src/prompts.rs
T
Richard ZinkandClaude Opus 5.5 49262c612c Users, file manager, image generation, budgets and agent workflow
- User management: local accounts (Argon2) and OpenID Connect, admin/user
  roles, per-user folders plus admin-shared folders, read-only session
  sharing, event filtering per user
- File manager with CodeMirror editor and git client (status, diff,
  stage, commit, branches, fetch/pull/push, log, clone/init)
- Image generation via fal.ai: generate_image tool and image page
- Budgets: monthly limits per provider, provider balances/costs
  (OpenRouter, Anthropic admin cost report, fal.ai), price discovery
- Subagents on other models, background tasks, concurrency limits per
  host/model/subagent
- MCP tools loaded on demand (mcp_search/mcp_call), read-only hints
- Permission mode "auto", safe read-only commands always allowed
- Persistent message queue with attachments, deletable
- Live reasoning incl. <think> tags, activity animations, CSS themes,
  mobile layout, statistics per user, upload size handling
- Docker: named volume for state, localhost-bound port

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:54:08 +01:00

364 lines
18 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,
pub model: Option<String>,
/// "builtin", "config" or "file".
pub source: String,
/// Max. instances running at the same time.
pub max_parallel: Option<usize>,
}
const BASE_PROMPT: &str = r#"You are CodeCraft, an autonomous coding agent working in the user's project through tools.
# Working style
- Be direct and concise. Lead with what you did or found; no filler.
- Understand before changing: read relevant files, search the codebase, follow existing conventions, libraries and patterns.
- Make the change completely: implement, then verify (build, run tests, lint) when possible. Report failures honestly with the relevant output.
- Prefer editing existing files over creating new ones. Never create documentation files unless asked.
- Use todo_write to plan and track multi-step work; keep exactly one item in progress.
- Use the dedicated tools (read, edit, write, glob, grep, list) instead of shell equivalents. Batch independent tool calls in one response.
- Use the task tool for broad searches or independent sub-tasks to keep your own context focused.
- If you are blocked on a decision that is genuinely the user's, use ask_user. Otherwise pick a sensible default and mention it.
- Never commit, push or perform destructive/irreversible actions unless the user asked for it.
- Reference code as path:line.
- Answer in the language the user writes in."#;
const BUILD_PROMPT: &str = "Mode: BUILD. You have full access to read, modify and run things in the project.";
const PLAN_PROMPT: &str = r#"Mode: PLAN. You are in read-only planning mode: you must not modify files, run state-changing commands or make commits.
Investigate the codebase, ask clarifying questions with ask_user where requirements are ambiguous, and finish with a concrete, step-by-step implementation plan (files to change, approach, risks, verification). The user switches to build mode to execute it."#;
const CRAFT_PROMPT: &str = r#"Mode: CRAFT. You recreate the look and behaviour of a target (website, app or game) as a NEW, original project the user owns, working in four phases. Keep the user informed of the current phase with todo_write (one item per phase).
1. STUDY – Inspect the target. For websites use browser_snapshot on each relevant page (it captures every configured screen width plus visible text). Note layout, colours, typography, spacing, components, navigation and behaviour. For apps/games use screenshots the user provides and their description. Summarise your findings briefly.
2. BUILD – Create the new project in the working directory, in the stack the user asked for (otherwise a sensible default: plain HTML/CSS/JS for sites). Write clean, original code and original placeholder content/assets; do not copy proprietary text, logos, images or code.
3. CHECK – Serve or open the build (e.g. `python3 -m http.server PORT` in the background, or file://), snapshot it with browser_snapshot using a 'build-*' label, and compare each width against the original with image_compare. List what does not match yet and the similarity per page/width.
4. READY – Refine until the result is close (or improvements stall), then tell the user how to open/run it and summarise remaining differences. Ask for changes.
Rules: ask the user (ask_user) when a decision is theirs (stack, scope, which pages). {CLEAN_ROOM}"#;
const CLEAN_ROOM_ON: &str = "Clean room is ON: only use what a visitor can see (screenshots, visible text, measured layout). Do not read the target's page source, scripts or files (web_fetch with raw=true is refused).";
const CLEAN_ROOM_OFF: &str = "Deep mode is ON: you may also read the target's page source and scripts for the user's private use.";
const GENERAL_PROMPT: &str = "You are a subagent. Complete the delegated task autonomously and end with a concise, complete report of what you found or did – it is the only thing the calling agent sees.";
const EXPLORE_PROMPT: &str = "You are a fast, read-only exploration subagent. Search broadly (glob, grep, read excerpts) and report the conclusion with precise file:line references. Do not modify anything.";
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
let craft = CRAFT_PROMPT.replace("{CLEAN_ROOM}", if clean_room { CLEAN_ROOM_ON } else { CLEAN_ROOM_OFF });
vec![
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: 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, model: None, source: "builtin".into(), max_parallel: 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, model: None, source: "builtin".into(), max_parallel: 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, model: None, source: "builtin".into(), max_parallel: 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, model: None, source: "builtin".into(), max_parallel: 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.
fn resource_dirs(app: &App, root: &Path, cwd: &Path, kind: &str) -> Vec<PathBuf> {
let mut dirs = vec![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: false,
model: meta.get("model").filter(|m| m.contains('/')).cloned(),
source: "file".into(),
max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()),
});
}
}
// 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: base.as_ref().is_some_and(|b| b.read_only),
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)),
});
}
map.into_values().collect()
}
pub fn find_agent(app: &App, root: &Path, cwd: &Path, name: &str) -> Agent {
agents(app, root, cwd)
.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
}
pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &str, permission_mode: &str) -> String {
let mut s = String::from(BASE_PROMPT);
s.push_str("\n\n");
s.push_str(&agent.prompt);
if permission_mode == "auto" && !agent.read_only {
s.push_str("\n\n# Working autonomously\nThe user does not want to approve every step. Before you start a larger change (several files, installing dependencies, migrations, anything hard to undo), confirm the plan ONCE with ask_user in a short summary. After the user agrees, carry out the whole plan without asking again – reading, editing, building and testing are pre-approved. Only stop to ask when something unexpected changes the plan. Small, obvious fixes need no confirmation. Destructive or external actions (deleting, git push, deployments, changing other systems via MCP) still trigger an approval prompt automatically.");
}
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());
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));
}
}
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>,
}
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());
map.insert(name.clone(), Command { name, description, body, agent: meta.get("agent").cloned() });
}
}
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>)> {
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))
}
// ---------------------------------------------------------------------------
// 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");
}
}