Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions crates/tui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
153 changes: 153 additions & 0 deletions crates/tui/src/prompts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,31 @@ static LOCALE_CLOSER_JA_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceL
static LOCALE_CLOSER_PT_BR_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
static LOCALE_CLOSER_VI_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
static AUTHORITY_RECAP_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
static STATIC_PROMPT_COMPOSER: std::sync::OnceLock<Box<StaticPromptComposer>> =
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
Expand Down Expand Up @@ -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<StaticPromptComposer>,
) -> Result<(), Box<StaticPromptComposer>> {
set_static_prompt_composer(&STATIC_PROMPT_COMPOSER, f)
}

fn set_prompt_override(cell: &std::sync::OnceLock<String>, s: String) -> Result<(), String> {
cell.set(s)
}

fn set_static_prompt_composer(
cell: &std::sync::OnceLock<Box<StaticPromptComposer>>,
f: Box<StaticPromptComposer>,
) -> Result<(), Box<StaticPromptComposer>> {
cell.set(f)
}

fn effective_prompt_override<'a>(
cell: &'a std::sync::OnceLock<String>,
fallback: &'static str,
Expand All @@ -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)
}
Comment on lines +411 to +413
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using Box::as_ref directly as a function pointer will fail to compile because Box does not have an inherent as_ref method. Instead, use a closure or AsRef::as_ref to map the OnceLock value.

Suggested change
fn effective_static_prompt_composer() -> Option<&'static StaticPromptComposer> {
STATIC_PROMPT_COMPOSER.get().map(Box::as_ref)
}
fn effective_static_prompt_composer() -> Option<&'static StaticPromptComposer> {
STATIC_PROMPT_COMPOSER.get().map(|b| b.as_ref())
}


fn effective_locale_preamble_zh_hans() -> &'static str {
effective_prompt_override(&LOCALE_PREAMBLE_ZH_HANS_OVERRIDE, LOCALE_PREAMBLE_ZH_HANS)
}
Expand Down Expand Up @@ -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,
)
Comment on lines +835 to +843
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Pass default_layers by value to avoid cloning it when no custom composer is set.

Suggested change
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,
)
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,
Expand All @@ -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(),
}
}
Comment on lines +870 to +886
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To avoid an unnecessary String allocation/clone when composer is None (which is the default/common path), we can pass default_layers by value (String) to apply_static_prompt_composer instead of borrowing it as &str.

Suggested change
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 apply_static_prompt_composer(
composer: Option<&StaticPromptComposer>,
personality: Personality,
model_id: &str,
shell_tools_available: bool,
default_layers: String,
) -> String {
match composer {
Some(composer) => composer(&StaticPromptCtx {
model_id,
personality,
shell_tools_available,
default_layers: &default_layers,
}),
None => default_layers,
}
}


fn render_base_prompt_for_tool_availability(
prompt: &str,
model_id: &str,
Expand Down Expand Up @@ -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<StaticPromptComposer> =
Box::new(|ctx| format!("first:{}", ctx.default_layers.len()));
let second: Box<StaticPromptComposer> =
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,
);
Comment on lines +1334 to +1340
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Clone default_layers here since it is used in the subsequent assertion.

Suggested change
let composed = apply_static_prompt_composer(
None,
personality,
"deepseek-v4-flash",
shell_tools_available,
&default_layers,
);
let composed = apply_static_prompt_composer(
None,
personality,
"deepseek-v4-flash",
shell_tools_available,
default_layers.clone(),
);


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<StaticPromptComposer> = 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,
);
Comment on lines +1362 to +1368
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Pass default_layers by value as it is not used after this call.

Suggested change
let composed = apply_static_prompt_composer(
Some(composer.as_ref()),
Personality::Calm,
"deepseek-v4-pro",
false,
&default_layers,
);
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!(
Expand Down
Loading