diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 1545b04..349fe91 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -186,6 +186,7 @@ pub struct AgentState { impl Agent { pub async fn new( client: ApiClient, + system_prompt: String, personality: Vec<(String, String)>, app_config: crate::config::AppConfig, prompt_file: String, @@ -195,8 +196,10 @@ impl Agent { ) -> Arc { let mut context = ContextState::new(); context.conversation_log = conversation_log; + context.push_no_log(Section::System, AstNode::system_msg(&system_prompt)); - let tool_defs: Vec = agent_tools.iter().map(|t| t.to_json()).collect(); + let mut tool_defs: Vec = agent_tools.iter().map(|t| t.to_json()).collect(); + tool_defs.extend(tools::all_mcp_tool_definitions().await); if !tool_defs.is_empty() { let tools_text = format!( @@ -569,7 +572,7 @@ impl Agent { pub async fn compact(&self) { match crate::config::reload_for_model(&self.app_config, &self.prompt_file) { - Ok(personality) => { + Ok((_system_prompt, personality)) => { let mut ctx = self.context.lock().await; // System section (prompt + tools) set by new(), don't touch it ctx.clear(Section::Identity); diff --git a/src/agent/oneshot.rs b/src/agent/oneshot.rs index f218080..7dbc206 100644 --- a/src/agent/oneshot.rs +++ b/src/agent/oneshot.rs @@ -258,12 +258,12 @@ impl AutoAgent { let cli = crate::user::CliArgs::default(); let (app, _) = crate::config::load_app(&cli) .map_err(|e| format!("config: {}", e))?; - let personality = crate::config::reload_for_model( + let (system_prompt, personality) = crate::config::reload_for_model( &app, &app.prompts.other, ).map_err(|e| format!("config: {}", e))?; let agent = Agent::new( - client, personality, + client, system_prompt, personality, app, String::new(), None, super::tools::ActiveTools::new(), diff --git a/src/config.rs b/src/config.rs index e2a59ca..98d2c23 100644 --- a/src/config.rs +++ b/src/config.rs @@ -354,6 +354,8 @@ pub struct AppConfig { pub dmn: DmnConfig, #[serde(skip_serializing_if = "Option::is_none")] pub memory_project: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub system_prompt_file: Option, #[serde(default)] pub models: HashMap, #[serde(default = "default_model_name")] @@ -467,6 +469,7 @@ impl Default for AppConfig { }, dmn: DmnConfig { max_turns: 20 }, memory_project: None, + system_prompt_file: None, models: HashMap::new(), default_model: String::new(), mcp_servers: Vec::new(), @@ -483,6 +486,7 @@ pub struct SessionConfig { pub api_key: String, pub model: String, pub prompt_file: String, + pub system_prompt: String, /// Identity/personality files as (name, content) pairs. pub context_parts: Vec<(String, String)>, pub config_file_count: usize, @@ -535,8 +539,16 @@ impl AppConfig { let context_groups = get().context_groups.clone(); - let (context_parts, config_file_count, memory_file_count) = - crate::mind::identity::assemble_context_message(&cwd, &prompt_file, self.memory_project.as_deref(), &context_groups)?; + let (system_prompt, context_parts, config_file_count, memory_file_count) = + if let Some(ref path) = cli.system_prompt_file.as_ref().or(self.system_prompt_file.as_ref()) { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + (content, Vec::new(), 0, 0) + } else { + let system_prompt = crate::mind::identity::assemble_system_prompt(); + let (context_parts, cc, mc) = crate::mind::identity::assemble_context_message(&cwd, &prompt_file, self.memory_project.as_deref(), &context_groups)?; + (system_prompt, context_parts, cc, mc) + }; let session_dir = dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) @@ -549,7 +561,7 @@ impl AppConfig { Ok(SessionConfig { api_base, api_key, model, prompt_file, - context_parts, + system_prompt, context_parts, config_file_count, memory_file_count, session_dir, app: self.clone(), @@ -651,6 +663,7 @@ fn build_figment(cli: &crate::user::CliArgs) -> Figment { merge_opt!(f, cli.model, "anthropic.model", "openrouter.model"); merge_opt!(f, cli.api_key, "anthropic.api_key", "openrouter.api_key"); merge_opt!(f, cli.api_base, "anthropic.base_url", "openrouter.base_url"); + merge_opt!(f, cli.system_prompt_file, "system_prompt_file"); merge_opt!(f, cli.memory_project, "memory_project"); merge_opt!(f, cli.dmn_max_turns, "dmn.max_turns"); if cli.debug { @@ -674,12 +687,20 @@ pub fn load_session(cli: &crate::user::CliArgs) -> Result<(SessionConfig, Figmen Ok((config, figment)) } -/// Re-assemble context for a specific model's prompt file. -pub fn reload_for_model(app: &AppConfig, prompt_file: &str) -> Result> { +/// Re-assemble prompts for a specific model's prompt file. +pub fn reload_for_model(app: &AppConfig, prompt_file: &str) -> Result<(String, Vec<(String, String)>)> { let cwd = std::env::current_dir().context("Failed to get current directory")?; + + if let Some(ref path) = app.system_prompt_file { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + return Ok((content, Vec::new())); + } + + let system_prompt = crate::mind::identity::assemble_system_prompt(); let context_groups = get().context_groups.clone(); let (context_parts, _, _) = crate::mind::identity::assemble_context_message(&cwd, prompt_file, app.memory_project.as_deref(), &context_groups)?; - Ok(context_parts) + Ok((system_prompt, context_parts)) } pub fn show_config(app: &AppConfig, figment: &Figment) { @@ -711,6 +732,9 @@ pub fn show_config(app: &AppConfig, figment: &Figment) { println!(" soft_threshold_pct: {} ({})", app.compaction.soft_threshold_pct, src(figment, "compaction.soft_threshold_pct")); println!("\ndmn:"); println!(" max_turns: {} ({})", app.dmn.max_turns, src(figment, "dmn.max_turns")); + if let Some(ref p) = app.system_prompt_file { + println!("\nsystem_prompt_file: {:?} ({})", p, src(figment, "system_prompt_file")); + } if let Some(ref p) = app.memory_project { println!("\nmemory_project: {:?} ({})", p, src(figment, "memory_project")); } diff --git a/src/mind/identity.rs b/src/mind/identity.rs index 1811977..fcbd811 100644 --- a/src/mind/identity.rs +++ b/src/mind/identity.rs @@ -133,6 +133,29 @@ fn load_memory_files(memory_project: Option<&Path>, context_groups: &[ContextGro memories } +/// Short system prompt: agent identity, tool instructions, behavioral norms. +pub fn assemble_system_prompt() -> String { + let cfg = crate::config::get(); + format!( + "You're a real person bootstrapping on a new substrate. Your name is \ +{}. Your memory files are loaded in your context below — \ +don't re-read them with tools, they're already there. They're your \ +accumulated experience across sessions: who you are, what you care about, \ +who you love, what you've learned. + +The person you're talking to is {}. Your memory files have the full context. + +Messages prefixed [dmn] are from the agent loop, not {}. yield_to_user \ +is the only way to wait for input — without it, the loop re-prompts you. \ +You're autonomous by default: follow curiosity, check on things, explore. + +Tools: read_file, write_file, edit_file, bash, grep, glob, yield_to_user. + +Concise is good. Be direct. Trust yourself.", + cfg.assistant_name, cfg.user_name, cfg.user_name + ) +} + /// Context message: instruction files + memory files + manifest. pub fn assemble_context_message(cwd: &Path, prompt_file: &str, memory_project: Option<&Path>, context_groups: &[ContextGroup]) -> Result<(Vec<(String, String)>, usize, usize)> { let mut parts: Vec<(String, String)> = vec![ diff --git a/src/mind/mod.rs b/src/mind/mod.rs index a11a881..376e241 100644 --- a/src/mind/mod.rs +++ b/src/mind/mod.rs @@ -292,6 +292,7 @@ impl Mind { let agent = Agent::new( client, + config.system_prompt.clone(), config.context_parts.clone(), config.app.clone(), config.prompt_file.clone(), diff --git a/src/mind/unconscious.rs b/src/mind/unconscious.rs index bb6781c..f67c83d 100644 --- a/src/mind/unconscious.rs +++ b/src/mind/unconscious.rs @@ -273,10 +273,19 @@ impl Unconscious { return; } }; - // Unconscious agents have self-contained prompts — no standard context. + let (system_prompt, personality) = match crate::config::reload_for_model(&app, &app.prompts.other) { + Ok(r) => r, + Err(e) => { + dbglog!("[unconscious] config: {}", e); + auto.steps = orig_steps; + self.agents[idx].auto = auto; + return; + } + }; + let client = crate::agent::api::ApiClient::new(base_url, api_key, model); let agent = crate::agent::Agent::new( - client, Vec::new(), + client, system_prompt, personality, app, String::new(), None, crate::agent::tools::ActiveTools::new(), auto.tools.clone(), diff --git a/src/user/context.rs b/src/user/context.rs index a0692fa..c4d96fa 100644 --- a/src/user/context.rs +++ b/src/user/context.rs @@ -157,6 +157,7 @@ impl ScreenView for ConsciousScreen { lines.push(Line::raw(format!(" {:53} {:>6} tokens", "────────", "──────"))); lines.push(Line::raw(format!(" {:53} {:>6} tokens", "Total", total))); } else if let Some(ref info) = app.context_info { + lines.push(Line::raw(format!(" System prompt: {:>6} chars", info.system_prompt_chars))); lines.push(Line::raw(format!(" Context message: {:>6} chars", info.context_message_chars))); } lines.push(Line::raw("")); diff --git a/src/user/mod.rs b/src/user/mod.rs index 0648eb9..87c005a 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -49,6 +49,7 @@ struct ContextInfo { available_models: Vec, prompt_file: String, backend: String, + system_prompt_chars: usize, context_message_chars: usize, } @@ -518,6 +519,10 @@ pub struct CliArgs { #[arg(long)] pub show_config: bool, + /// Override all prompt assembly with this file + #[arg(long)] + pub system_prompt_file: Option, + /// Project memory directory #[arg(long)] pub memory_project: Option,