diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 90850982..db7f7ca4 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -91,11 +91,15 @@ pub struct EngineConfig { pub mcp_config_path: PathBuf, /// Directory containing discoverable skills. pub skills_dir: PathBuf, - /// Additional instruction files concatenated into the system - /// prompt (#454). Loaded in declared order from the user's - /// `instructions = [...]` config (or the per-project override). - /// Resolved via `expand_path` so `~` works. - pub instructions: Vec, + /// Sources injected as `` blocks in the system + /// prompt (#454). Each entry is either a disk path (read at render time) + /// or an inline string. Loaded in declared order from the user's + /// `instructions = [...]` config or constructed by embedders. + /// + /// Generalized from `Vec` so embedders can inject inline content + /// without staging a disk file. `From` impl keeps existing callers + /// working with `.into()` at the call site. + pub instructions: Vec, pub project_context_pack_enabled: bool, /// When true, the model is instructed to respond in the current locale /// and a post-hoc translation layer replaces remaining English output. diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 8043e5a4..e2c52d94 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -5250,7 +5250,11 @@ async fn run_exec_agent( notes_path: config.notes_path(), mcp_config_path: config.mcp_config_path(), skills_dir: config.skills_dir(), - instructions: config.instructions_paths(), + instructions: config + .instructions_paths() + .into_iter() + .map(Into::into) + .collect(), project_context_pack_enabled: config.project_context_pack_enabled(), translation_enabled: false, show_thinking: settings.show_thinking, diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 22b77643..3a9f1839 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -159,44 +159,88 @@ fn render_environment_block(workspace: &Path, locale_tag: &str) -> String { ) } +/// Source for an `EngineConfig.instructions` entry. Either a disk file (loaded +/// at render time, original semantics) or an inline string (content baked into +/// `EngineConfig`, no disk I/O at render time). +/// +/// The inline variant is useful for embedders that compute instructions at +/// runtime (e.g. rendering a template with workspace-specific substitutions) +/// and don't want to stage the content to a disk file just to satisfy a path +/// API. Staging adds two problems the inline path avoids: +/// +/// 1. The disk file looks like editable config but gets overwritten on +/// every launch — confusing for users browsing the install dir. +/// 2. Multi-engine setups need per-engine paths to avoid `rehydrate` +/// reading another session's instructions; with inline sources the +/// content lives in the per-engine `EngineConfig` and the race +/// surface goes away. +/// +/// `From` is provided so existing callers passing `Vec` can +/// keep working with a `.into()` upgrade at the call site. +#[derive(Debug, Clone)] +pub enum InstructionSource { + /// Load this file from disk at prompt-render time. Original behavior: + /// missing files are skipped with a warning, oversized files are + /// truncated to `INSTRUCTIONS_FILE_MAX_BYTES` with an `[…elided]` + /// marker. + File(PathBuf), + /// Use the provided string directly. `name` becomes the + /// `` attribute (typically a synthetic + /// identifier like `embedded:my-template` or a logical path). + Inline { name: String, content: String }, +} + +impl From for InstructionSource { + fn from(path: PathBuf) -> Self { + InstructionSource::File(path) + } +} + +impl From<&PathBuf> for InstructionSource { + fn from(path: &PathBuf) -> Self { + InstructionSource::File(path.clone()) + } +} + /// Render the `instructions = [...]` config array as a single -/// system-prompt block (#454). Each path is loaded in declared order; -/// missing files are skipped with a tracing warning so a stale entry -/// in `~/.deepseek/config.toml` doesn't fail the launch. Empty input -/// (or all paths missing) returns `None` so callers append nothing. -fn render_instructions_block(paths: &[PathBuf]) -> Option { +/// system-prompt block (#454). Each source is processed in declared order; +/// missing `File` sources are skipped with a tracing warning so a stale entry +/// doesn't fail the launch. Empty input (or all sources missing/empty) +/// returns `None` so callers append nothing. +fn render_instructions_block(sources: &[InstructionSource]) -> Option { let mut sections: Vec = Vec::new(); - for path in paths { - match std::fs::read_to_string(path) { - Ok(raw) => { - let trimmed = raw.trim(); - if trimmed.is_empty() { + for source in sources { + let (raw_source_name, raw_content): (String, String) = match source { + InstructionSource::File(path) => match std::fs::read_to_string(path) { + Ok(raw) => (path.display().to_string(), raw), + Err(err) => { + tracing::warn!( + target: "instructions", + ?err, + ?path, + "skipping unreadable instructions file" + ); continue; } - let body = if trimmed.len() > INSTRUCTIONS_FILE_MAX_BYTES { - let head_end = (0..=INSTRUCTIONS_FILE_MAX_BYTES) - .rev() - .find(|&i| trimmed.is_char_boundary(i)) - .unwrap_or(0); - format!("{}\n[…elided]", &trimmed[..head_end]) - } else { - trimmed.to_string() - }; - sections.push(format!( - "\n{}\n", - path.display(), - body - )); - } - Err(err) => { - tracing::warn!( - target: "instructions", - ?err, - ?path, - "skipping unreadable instructions file" - ); - } + }, + InstructionSource::Inline { name, content } => (name.clone(), content.clone()), + }; + let trimmed = raw_content.trim(); + if trimmed.is_empty() { + continue; } + let body = if trimmed.len() > INSTRUCTIONS_FILE_MAX_BYTES { + let head_end = (0..=INSTRUCTIONS_FILE_MAX_BYTES) + .rev() + .find(|&i| trimmed.is_char_boundary(i)) + .unwrap_or(0); + format!("{}\n[…elided]", &trimmed[..head_end]) + } else { + trimmed.to_string() + }; + sections.push(format!( + "\n{body}\n" + )); } if sections.is_empty() { None @@ -682,7 +726,7 @@ pub fn system_prompt_for_mode_with_context_and_skills( workspace: &Path, working_set_summary: Option<&str>, skills_dir: Option<&Path>, - instructions: Option<&[PathBuf]>, + instructions: Option<&[InstructionSource]>, user_memory_block: Option<&str>, ) -> SystemPrompt { system_prompt_for_mode_with_context_skills_and_session( @@ -708,7 +752,7 @@ pub fn system_prompt_for_mode_with_context_skills_and_session( workspace: &Path, _working_set_summary: Option<&str>, skills_dir: Option<&Path>, - instructions: Option<&[PathBuf]>, + instructions: Option<&[InstructionSource]>, session_context: PromptSessionContext<'_>, ) -> SystemPrompt { system_prompt_for_mode_with_context_skills_session_and_approval( @@ -727,7 +771,7 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval( workspace: &Path, _working_set_summary: Option<&str>, skills_dir: Option<&Path>, - instructions: Option<&[PathBuf]>, + instructions: Option<&[InstructionSource]>, session_context: PromptSessionContext<'_>, approval_mode: ApprovalMode, ) -> SystemPrompt { @@ -856,8 +900,8 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval( // because these files are workspace-scoped and may differ between // sessions; any edit to them would otherwise bust the prefix cache for // all subsequent static layers. - if let Some(paths) = instructions - && let Some(block) = render_instructions_block(paths) + if let Some(sources) = instructions + && let Some(block) = render_instructions_block(sources) { full_prompt = format!("{full_prompt}\n\n{block}"); } @@ -2047,10 +2091,6 @@ mod tests { #[test] fn workspace_orientation_guidance_present() { let prompt = compose_prompt(AppMode::Agent, Personality::Calm); - // Workspace orientation guidance is now distributed across the - // Constitutional preamble (project context loading) and the - // Local Law tier (AGENTS.md/instructions.md). Verify the - // key guidance anchors are still present. assert!(prompt.contains("AGENTS.md")); assert!(prompt.contains("Local Law")); assert!( @@ -2292,7 +2332,8 @@ mod tests { #[test] fn render_instructions_block_returns_none_for_empty_input() { - assert!(super::render_instructions_block(&[]).is_none()); + let empty: &[super::InstructionSource] = &[]; + assert!(super::render_instructions_block(empty).is_none()); } #[test] @@ -2302,7 +2343,7 @@ mod tests { std::fs::write(&real, "real content here").unwrap(); let bogus = tmp.path().join("does-not-exist.md"); - let block = super::render_instructions_block(&[bogus.clone(), real.clone()]) + let block = super::render_instructions_block(&[bogus.clone().into(), real.clone().into()]) .expect("present file should produce a block"); assert!(block.contains("real content here")); assert!(block.contains(&real.display().to_string())); @@ -2318,7 +2359,7 @@ mod tests { std::fs::write(&a, "ALPHA_MARKER").unwrap(); std::fs::write(&b, "BRAVO_MARKER").unwrap(); - let block = super::render_instructions_block(&[a, b]).expect("non-empty"); + let block = super::render_instructions_block(&[a.into(), b.into()]).expect("non-empty"); let alpha_pos = block.find("ALPHA_MARKER").expect("alpha rendered"); let bravo_pos = block.find("BRAVO_MARKER").expect("bravo rendered"); assert!( @@ -2335,7 +2376,8 @@ mod tests { std::fs::write(&empty, " \n \n").unwrap(); std::fs::write(&real, "real content").unwrap(); - let block = super::render_instructions_block(&[empty, real]).expect("non-empty"); + let block = + super::render_instructions_block(&[empty.into(), real.into()]).expect("non-empty"); // Empty file produces no `` section, only the real one. let count = block.matches("` attribute. + /// Empty / oversize handling mirrors `File` variant. + #[test] + fn render_instructions_block_handles_inline_source() { + let block = super::render_instructions_block(&[super::InstructionSource::Inline { + name: "embedded:test/template".to_string(), + content: "INLINE_MARKER_CONTENT".to_string(), + }]) + .expect("non-empty"); + assert!(block.contains("INLINE_MARKER_CONTENT")); + assert!(block.contains("source=\"embedded:test/template\"")); + + // Empty inline → skipped just like empty file. + let empty_inline = super::InstructionSource::Inline { + name: "empty".to_string(), + content: " ".to_string(), + }; + assert!(super::render_instructions_block(&[empty_inline]).is_none()); + + // Oversize inline → truncated with elided marker. + let big_inline = super::InstructionSource::Inline { + name: "huge".to_string(), + content: "Y".repeat(200 * 1024), + }; + let trimmed = super::render_instructions_block(&[big_inline]).expect("non-empty"); + assert!(trimmed.contains("[…elided]")); + + // File + Inline 混用,顺序保持。 + let tmp = tempdir().expect("tempdir"); + let file_path = tmp.path().join("file-first.md"); + std::fs::write(&file_path, "FILE_MARKER").unwrap(); + let mixed = super::render_instructions_block(&[ + file_path.into(), + super::InstructionSource::Inline { + name: "inline-second".to_string(), + content: "INLINE_MARKER".to_string(), + }, + ]) + .expect("non-empty"); + let file_pos = mixed.find("FILE_MARKER").expect("file rendered"); + let inline_pos = mixed.find("INLINE_MARKER").expect("inline rendered"); + assert!(file_pos < inline_pos, "声明顺序必须保留(File then Inline)"); + } + #[test] fn instructions_block_appears_in_system_prompt_when_configured() { let tmp = tempdir().expect("tempdir"); @@ -2364,12 +2451,13 @@ mod tests { let extra = workspace.join("extra-instructions.md"); std::fs::write(&extra, "EXTRA_INSTRUCTIONS_MARKER_BODY").unwrap(); + let extra_source: super::InstructionSource = extra.clone().into(); let prompt = match super::system_prompt_for_mode_with_context_and_skills( AppMode::Agent, workspace, None, None, - Some(std::slice::from_ref(&extra)), + Some(std::slice::from_ref(&extra_source)), None, ) { SystemPrompt::Text(text) => text, diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 919d501a..3b90bc60 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -1945,7 +1945,12 @@ impl RuntimeThreadManager { notes_path: self.config.notes_path(), mcp_config_path: self.config.mcp_config_path(), skills_dir: self.config.skills_dir(), - instructions: self.config.instructions_paths(), + instructions: self + .config + .instructions_paths() + .into_iter() + .map(Into::into) + .collect(), project_context_pack_enabled: self.config.project_context_pack_enabled(), translation_enabled: false, show_thinking: settings.show_thinking, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 0ee3d1c0..9cb5b5a9 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -718,7 +718,11 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { notes_path: config.notes_path(), mcp_config_path: config.mcp_config_path(), skills_dir: app.skills_dir.clone(), - instructions: config.instructions_paths(), + instructions: config + .instructions_paths() + .into_iter() + .map(Into::into) + .collect(), project_context_pack_enabled: config.project_context_pack_enabled(), translation_enabled: app.translation_enabled, show_thinking: app.show_thinking,