diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a1c0e2d0..9d7fafeb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Agent View prompt, @lbcheng888 for the earlier scaffold, and @BigBenLabs, @lzx1545642258, @yangdaowan, @mangdehuang, @VerrPower, @hejia-v, @nasus9527, and @ygzhang-cn for the GUI/VS Code demand and validation trail. +- Added a static prompt composer override for embedders that need to replace + the byte-stable base/personality prompt segment while leaving mode metadata, + approval policy, tool taxonomy, Context Management, and the Compaction Relay + under CodeWhale's runtime prompt assembly. This refines the embedder prompt + customization path from #2786 without weakening prompt-continuity safeguards. + Thanks @h3c-hexin. - Added `POST /v1/sessions` for runtime clients to save a completed thread as a managed session. The endpoint preserves thread title/model/mode/workspace metadata, maps missing threads to 404, and returns 409 instead of snapshotting diff --git a/README.md b/README.md index 01c97fc12..a9b7d3e69 100644 --- a/README.md +++ b/README.md @@ -663,8 +663,8 @@ Current v0.9 track credits: HarnessPosture provider/model policy direction (#2733, #2738, #2734, #2741, #2692, #2694, #2693) - **[h3c-hexin](https://github.com/h3c-hexin)** — sub-agent model inheritance, - configured `skills_dir` discovery, and prompt-environment stability work - (#2736, #2737) + configured `skills_dir` discovery, prompt-environment stability, and static + prompt composer direction (#2736, #2737, #2786) - **[gaord](https://github.com/gaord)** — runtime thread workspace updates and completed-thread saved-session API work (#2640, #2639) - **[cyq1017](https://github.com/cyq1017)** — restore-listing and diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 7a1c0e2d0..9d7fafeb2 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -52,6 +52,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Agent View prompt, @lbcheng888 for the earlier scaffold, and @BigBenLabs, @lzx1545642258, @yangdaowan, @mangdehuang, @VerrPower, @hejia-v, @nasus9527, and @ygzhang-cn for the GUI/VS Code demand and validation trail. +- Added a static prompt composer override for embedders that need to replace + the byte-stable base/personality prompt segment while leaving mode metadata, + approval policy, tool taxonomy, Context Management, and the Compaction Relay + under CodeWhale's runtime prompt assembly. This refines the embedder prompt + customization path from #2786 without weakening prompt-continuity safeguards. + Thanks @h3c-hexin. - Added `POST /v1/sessions` for runtime clients to save a completed thread as a managed session. The endpoint preserves thread title/model/mode/workspace metadata, maps missing threads to 404, and returns 409 instead of snapshotting diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index a959f053f..49015f5a2 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -299,6 +299,31 @@ static LOCALE_CLOSER_JA_OVERRIDE: std::sync::OnceLock = std::sync::OnceL static LOCALE_CLOSER_PT_BR_OVERRIDE: std::sync::OnceLock = std::sync::OnceLock::new(); static LOCALE_CLOSER_VI_OVERRIDE: std::sync::OnceLock = std::sync::OnceLock::new(); static AUTHORITY_RECAP_OVERRIDE: std::sync::OnceLock = std::sync::OnceLock::new(); +static STATIC_PROMPT_COMPOSER: std::sync::OnceLock> = + std::sync::OnceLock::new(); + +/// Context passed to an embedder-provided static prompt composer. +/// +/// This hook only replaces the byte-stable base/personality prompt segment. +/// Mode deltas, approval policy, tool taxonomy, Context Management, and the +/// Compaction Relay stay owned by CodeWhale's runtime prompt assembly. +#[non_exhaustive] +#[derive(Debug)] +pub struct StaticPromptCtx<'a> { + /// Active model identifier after caller-side routing. + pub model_id: &'a str, + /// Personality overlay requested for the base static prompt. + pub personality: Personality, + /// Whether shell tools are present in the runtime tool catalog. + pub shell_tools_available: bool, + /// Default base/personality prompt layers that would be used without an + /// override. + pub default_layers: &'a str, +} + +/// Embedder hook for replacing CodeWhale's byte-stable base/personality prompt +/// segment. +pub type StaticPromptComposer = dyn Fn(&StaticPromptCtx<'_>) -> String + Send + Sync + 'static; /// Replace `BASE_PROMPT` for all subsequent prompt composition. First call /// wins; later calls return the rejected string. Set before spawning any @@ -352,10 +377,26 @@ pub fn set_authority_recap_override(s: String) -> Result<(), String> { set_prompt_override(&AUTHORITY_RECAP_OVERRIDE, s) } +/// Replace the byte-stable base/personality prompt segment for subsequent +/// prompt composition. First call wins; later calls return the rejected +/// composer so embedders can preserve ownership. +pub fn set_static_prompt_composer_override( + f: Box, +) -> Result<(), Box> { + set_static_prompt_composer(&STATIC_PROMPT_COMPOSER, f) +} + fn set_prompt_override(cell: &std::sync::OnceLock, s: String) -> Result<(), String> { cell.set(s) } +fn set_static_prompt_composer( + cell: &std::sync::OnceLock>, + f: Box, +) -> Result<(), Box> { + cell.set(f) +} + fn effective_prompt_override<'a>( cell: &'a std::sync::OnceLock, fallback: &'static str, @@ -367,6 +408,10 @@ fn effective_base_prompt() -> &'static str { effective_prompt_override(&BASE_PROMPT_OVERRIDE, BASE_PROMPT) } +fn effective_static_prompt_composer() -> Option<&'static StaticPromptComposer> { + STATIC_PROMPT_COMPOSER.get().map(Box::as_ref) +} + fn effective_locale_preamble_zh_hans() -> &'static str { effective_prompt_override(&LOCALE_PREAMBLE_ZH_HANS_OVERRIDE, LOCALE_PREAMBLE_ZH_HANS) } @@ -787,6 +832,22 @@ fn compose_prompt_with_approval_model_and_shell( allow_shell: bool, ) -> String { let shell_tools_available = allow_shell && mode != AppMode::Plan; + let default_layers = + compose_default_static_layers(personality, model_id, shell_tools_available); + apply_static_prompt_composer( + effective_static_prompt_composer(), + personality, + model_id, + shell_tools_available, + &default_layers, + ) +} + +fn compose_default_static_layers( + personality: Personality, + model_id: &str, + shell_tools_available: bool, +) -> String { let base_prompt = render_base_prompt_for_tool_availability( effective_base_prompt().trim(), model_id, @@ -806,6 +867,24 @@ fn compose_prompt_with_approval_model_and_shell( out } +fn apply_static_prompt_composer( + composer: Option<&StaticPromptComposer>, + personality: Personality, + model_id: &str, + shell_tools_available: bool, + default_layers: &str, +) -> String { + match composer { + Some(composer) => composer(&StaticPromptCtx { + model_id, + personality, + shell_tools_available, + default_layers, + }), + None => default_layers.to_string(), + } +} + fn render_base_prompt_for_tool_availability( prompt: &str, model_id: &str, @@ -1217,6 +1296,80 @@ mod tests { assert_eq!(effective_prompt_override(&cell, "fallback"), "first"); } + #[test] + fn static_prompt_composer_storage_returns_rejected_composer() { + let cell = std::sync::OnceLock::new(); + let first: Box = + Box::new(|ctx| format!("first:{}", ctx.default_layers.len())); + let second: Box = + Box::new(|ctx| format!("second:{}", ctx.default_layers.len())); + + assert!(set_static_prompt_composer(&cell, first).is_ok()); + let rejected = set_static_prompt_composer(&cell, second) + .err() + .expect("second composer should be rejected"); + let ctx = StaticPromptCtx { + model_id: "deepseek-v4-pro", + personality: Personality::Calm, + shell_tools_available: true, + default_layers: "fallback", + }; + + assert_eq!(rejected(&ctx), "second:8"); + assert_eq!( + cell.get().expect("first composer retained")(&ctx), + "first:8" + ); + } + + #[test] + fn static_prompt_composer_unset_keeps_default_layers_byte_identical() { + for personality in [Personality::Calm, Personality::Playful] { + for shell_tools_available in [true, false] { + let default_layers = compose_default_static_layers( + personality, + "deepseek-v4-flash", + shell_tools_available, + ); + let composed = apply_static_prompt_composer( + None, + personality, + "deepseek-v4-flash", + shell_tools_available, + &default_layers, + ); + + assert_byte_identical("unset static prompt composer", &default_layers, &composed); + } + } + } + + #[test] + fn static_prompt_composer_receives_context_and_replaces_layers() { + let default_layers = + compose_default_static_layers(Personality::Calm, "deepseek-v4-pro", false); + let composer: Box = Box::new(|ctx| { + assert_eq!(ctx.model_id, "deepseek-v4-pro"); + assert_eq!(ctx.personality, Personality::Calm); + assert!(!ctx.shell_tools_available); + assert!(ctx.default_layers.contains("You are deepseek-v4-pro")); + assert!(ctx.default_layers.contains("Personality: Calm")); + assert!(!ctx.default_layers.contains("## Core Tool Taxonomy")); + assert!(!ctx.default_layers.contains("Approval Policy")); + "embedder static prompt".to_string() + }); + + let composed = apply_static_prompt_composer( + Some(composer.as_ref()), + Personality::Calm, + "deepseek-v4-pro", + false, + &default_layers, + ); + + assert_eq!(composed, "embedder static prompt"); + } + fn contains_cjk(text: &str) -> bool { text.chars().any(|ch| { matches!(