feat(prompts): add static prompt composer override for embedders#2786
feat(prompts): add static prompt composer override for embedders#2786h3c-hexin wants to merge 1 commit into
Conversation
Embedders today have two prompt-customization options with a gap between them: per-block overrides (set_base_prompt_override, locale, authority recap) replace individual constants but any newly added static block still lands in the embedder's prompt unseen, while Op::SyncSession's system_prompt_override replaces the whole prompt and forfeits all dynamic assembly (environment, skills catalogue, instructions, memory, compaction-summary merge). set_static_prompt_composer_override fills the middle tier: an opt-in hook that takes over ALL compile-time static doctrine (tool taxonomy, base prompt, personality, mode delta, approval policy, Context Management, compaction relay template) while keeping the runtime's dynamic assembly intact. The composer receives a #[non_exhaustive] StaticPromptCtx (mode, approval_mode, model_id, allow_shell) plus the default composition for reference or partial reuse. No prompt content changes: when the hook is unset the output is byte-identical to before (covered by a dedicated test). Tests inject the composer as a parameter to avoid poisoning the process-global OnceLock for other tests.
|
Thanks @h3c-hexin for taking the time to contribute. This repository is currently observing a maintainer-managed contribution gate in dry-run mode, so this pull request is staying open. When enforcement is enabled, pull requests from contributors who are not listed in Please read |
There was a problem hiding this comment.
Code Review
This pull request introduces a static-layer composer override mechanism to allow embedders to take over compile-time static prompt layers. The review feedback highlights that the newly introduced StaticPromptCtx struct is missing the personality field, which is necessary for customizing the prompt's voice/tone. It is recommended to add this field, simplify redundant type paths, propagate the personality parameter through the composer application logic, and update the corresponding unit tests.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| pub struct StaticPromptCtx<'a> { | ||
| /// Active app mode (Agent / Plan / Yolo). | ||
| pub mode: crate::tui::app::AppMode, | ||
| /// Effective approval mode for this session. | ||
| pub approval_mode: crate::tui::approval::ApprovalMode, | ||
| /// Active model identifier (replaces `{model_id}` in base prompts). | ||
| pub model_id: &'a str, | ||
| /// Whether shell tools are exposed to the model this session. | ||
| pub allow_shell: bool, | ||
| /// The static layers the default composition would have produced — | ||
| /// for reference or partial reuse by the composer. | ||
| pub default_layers: &'a str, | ||
| } |
There was a problem hiding this comment.
The StaticPromptCtx struct is missing the personality field. The personality (e.g., Personality::Calm vs Personality::Playful) is a key part of the static prompt layers. If an embedder wants to take over the static prompt composition, they need to know the active personality voice/tone overlay to customize the prompt correctly.
Additionally, we can simplify the redundant fully-qualified type paths crate::tui::app::AppMode and crate::tui::approval::ApprovalMode to AppMode and ApprovalMode since they are already imported in this file.
| pub struct StaticPromptCtx<'a> { | |
| /// Active app mode (Agent / Plan / Yolo). | |
| pub mode: crate::tui::app::AppMode, | |
| /// Effective approval mode for this session. | |
| pub approval_mode: crate::tui::approval::ApprovalMode, | |
| /// Active model identifier (replaces `{model_id}` in base prompts). | |
| pub model_id: &'a str, | |
| /// Whether shell tools are exposed to the model this session. | |
| pub allow_shell: bool, | |
| /// The static layers the default composition would have produced — | |
| /// for reference or partial reuse by the composer. | |
| pub default_layers: &'a str, | |
| } | |
| pub struct StaticPromptCtx<'a> { | |
| /// Active app mode (Agent / Plan / Yolo). | |
| pub mode: AppMode, | |
| /// Active personality voice/tone overlay. | |
| pub personality: Personality, | |
| /// Effective approval mode for this session. | |
| pub approval_mode: ApprovalMode, | |
| /// Active model identifier (replaces `{model_id}` in base prompts). | |
| pub model_id: &'a str, | |
| /// Whether shell tools are exposed to the model this session. | |
| pub allow_shell: bool, | |
| /// The static layers the default composition would have produced — | |
| /// for reference or partial reuse by the composer. | |
| pub default_layers: &'a str, | |
| } |
| ) -> String { | ||
| let default_layers = | ||
| compose_default_static_layers(mode, personality, approval_mode, model_id, allow_shell); | ||
| apply_static_prompt_composer( | ||
| static_prompt_composer(), | ||
| mode, | ||
| approval_mode, | ||
| model_id, | ||
| allow_shell, | ||
| default_layers, | ||
| ) | ||
| } | ||
|
|
||
| /// Apply a static-layer composer over the default composition. Split out | ||
| /// from the global-`OnceLock` lookup so tests can inject a composer | ||
| /// without poisoning process-global state. | ||
| fn apply_static_prompt_composer( | ||
| composer: Option<&StaticPromptComposer>, | ||
| mode: AppMode, | ||
| approval_mode: ApprovalMode, | ||
| model_id: &str, | ||
| allow_shell: bool, | ||
| default_layers: String, | ||
| ) -> String { | ||
| match composer { | ||
| Some(compose) => compose(&StaticPromptCtx { | ||
| mode, | ||
| approval_mode, | ||
| model_id, | ||
| allow_shell, | ||
| default_layers: &default_layers, | ||
| }), | ||
| None => default_layers, | ||
| } | ||
| } |
There was a problem hiding this comment.
Pass the personality parameter to apply_static_prompt_composer and populate it in StaticPromptCtx so that the custom static prompt composer has access to the active personality voice/tone overlay.
) -> String {
let default_layers =
compose_default_static_layers(mode, personality, approval_mode, model_id, allow_shell);
apply_static_prompt_composer(
static_prompt_composer(),
mode,
personality,
approval_mode,
model_id,
allow_shell,
default_layers,
)
}
/// Apply a static-layer composer over the default composition. Split out
/// from the global-`OnceLock` lookup so tests can inject a composer
/// without poisoning process-global state.
fn apply_static_prompt_composer(
composer: Option<&StaticPromptComposer>,
mode: AppMode,
personality: Personality,
approval_mode: ApprovalMode,
model_id: &str,
allow_shell: bool,
default_layers: String,
) -> String {
match composer {
Some(compose) => compose(&StaticPromptCtx {
mode,
personality,
approval_mode,
model_id,
allow_shell,
default_layers: &default_layers,
}),
None => default_layers,
}
}| let composer = |ctx: &StaticPromptCtx<'_>| -> String { | ||
| assert_eq!(ctx.mode, AppMode::Agent); | ||
| assert_eq!(ctx.approval_mode, ApprovalMode::Suggest); | ||
| assert_eq!(ctx.model_id, "test-model"); | ||
| assert!(ctx.allow_shell); | ||
| assert!(ctx.default_layers.contains("Personality: Calm")); | ||
| "EMBEDDER STATIC LAYERS".to_string() | ||
| }; | ||
| let composed = apply_static_prompt_composer( | ||
| Some(&composer), | ||
| AppMode::Agent, | ||
| ApprovalMode::Suggest, | ||
| "test-model", | ||
| true, | ||
| default_layers, | ||
| ); | ||
| assert_eq!(composed, "EMBEDDER STATIC LAYERS"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn static_prompt_composer_unset_keeps_default_layers_byte_identical() { | ||
| let default_layers = compose_default_static_layers( | ||
| AppMode::Agent, | ||
| Personality::Calm, | ||
| ApprovalMode::Suggest, | ||
| "test-model", | ||
| true, | ||
| ); | ||
| let composed = apply_static_prompt_composer( | ||
| None, | ||
| AppMode::Agent, | ||
| ApprovalMode::Suggest, | ||
| "test-model", | ||
| true, | ||
| default_layers.clone(), | ||
| ); |
There was a problem hiding this comment.
Update the tests to verify that the personality field is correctly passed through to the StaticPromptCtx and handled by apply_static_prompt_composer.
let composer = |ctx: &StaticPromptCtx<'_>| -> String {
assert_eq!(ctx.mode, AppMode::Agent);
assert_eq!(ctx.personality, Personality::Calm);
assert_eq!(ctx.approval_mode, ApprovalMode::Suggest);
assert_eq!(ctx.model_id, "test-model");
assert!(ctx.allow_shell);
assert!(ctx.default_layers.contains("Personality: Calm"));
"EMBEDDER STATIC LAYERS".to_string()
};
let composed = apply_static_prompt_composer(
Some(&composer),
AppMode::Agent,
Personality::Calm,
ApprovalMode::Suggest,
"test-model",
true,
default_layers,
);
assert_eq!(composed, "EMBEDDER STATIC LAYERS");
}
#[test]
fn static_prompt_composer_unset_keeps_default_layers_byte_identical() {
let default_layers = compose_default_static_layers(
AppMode::Agent,
Personality::Calm,
ApprovalMode::Suggest,
"test-model",
true,
);
let composed = apply_static_prompt_composer(
None,
AppMode::Agent,
Personality::Calm,
ApprovalMode::Suggest,
"test-model",
true,
default_layers.clone(),
);| pub fn set_static_prompt_composer_override(f: Box<StaticPromptComposer>) -> Result<(), ()> { | ||
| STATIC_PROMPT_COMPOSER.set(f).map_err(|_| ()) | ||
| } |
There was a problem hiding this comment.
set_static_prompt_composer_override returns Result<(), ()> while every other set_*_override in this file returns Result<(), String>. The unit error gives callers no diagnostic information — they can't even log "already set by X". The OnceLock gives back the rejected value on Err; mapping it to () discards the only actionable detail. Returning Err(()) is also a departure from the rest of the override family's contract, which may surprise embedders who rely on the common signature.
| pub fn set_static_prompt_composer_override(f: Box<StaticPromptComposer>) -> Result<(), ()> { | |
| STATIC_PROMPT_COMPOSER.set(f).map_err(|_| ()) | |
| } | |
| pub fn set_static_prompt_composer_override( | |
| f: Box<StaticPromptComposer>, | |
| ) -> Result<(), Box<StaticPromptComposer>> { | |
| STATIC_PROMPT_COMPOSER.set(f) | |
| } |
| /// Effective approval mode for this session. | ||
| pub approval_mode: crate::tui::approval::ApprovalMode, | ||
| /// Active model identifier (replaces `{model_id}` in base prompts). | ||
| pub model_id: &'a str, | ||
| /// Whether shell tools are exposed to the model this session. | ||
| pub allow_shell: bool, | ||
| /// The static layers the default composition would have produced — | ||
| /// for reference or partial reuse by the composer. | ||
| pub default_layers: &'a str, |
There was a problem hiding this comment.
personality is absent from StaticPromptCtx. It is one of the five parameters that reach compose_default_static_layers and directly affects ctx.default_layers content (the personality.prompt().trim() slot). An embedder who calls the public compose_prompt(mode, Personality::Lively) path with a composer installed receives default_layers shaped by Personality::Lively but has no ctx.personality field to branch on — they must resort to string-scanning ctx.default_layers to detect it. The struct is already #[non_exhaustive], so adding a pub personality: crate::tui::app::Personality field is non-breaking and completes the contract of "all inputs that shaped default_layers are visible to the composer".
|
Thanks @h3c-hexin. This is a strong continuation of the embedder prompt work from #2356, and the default path plus current CI look good. I am going to hold off merging unchanged because the new hook's contract needs a little tightening before we freeze it into the prompt surface:
This PR is focused and all current checks are green, so I would prefer to keep this branch and merge after that small update rather than close or rewrite it. Useful verification target: Thanks again for pushing this embedder customization path forward. |
|
Thanks @h3c-hexin. I re-reviewed this against the current v0.9.0 stewardship branch, especially after the runtime prompt metadata work that landed in #2801. I am going to leave this open rather than merge or harvest it today. The feature is focused and CI-clean, so I do not want to take it over while it is still maintainer-modifiable, but the API shape needs one more adjustment before it is release-safe on the current branch:
Suggested verification once updated: So the maintainer decision for v0.9.0 right now is: defer, not close, and not supersede. The idea is useful; it just needs to match the new static-vs-runtime prompt boundary. |
Refine the embedder static prompt composer direction from #2786 so it only owns the byte-stable base/personality prompt segment while runtime metadata, Context Management, and the compaction relay stay under CodeWhale prompt assembly. Co-authored-by: h3c-hexin <13790929+h3c-hexin@users.noreply.github.com>
|
Thanks again @h3c-hexin. I harvested the release-safe v0.9 slice of this into #2819, now merged into What landed is intentionally narrower than this branch: embedders can replace the byte-stable base/personality prompt segment through I also kept your credit in the commit co-author trailer, changelog, README v0.9 track credits, and the PR body. I am leaving this PR open rather than closing it because the broader "all static doctrine" ownership question can still be discussed separately from the narrower v0.9 hook that landed. Verification on the harvest PR/local branch included:
And #2762 is green again after the merge. Appreciate you pushing this embedder customization path forward. |
What
Adds
set_static_prompt_composer_override— an opt-in hook letting an embedder take over ALL compile-time static prompt doctrine (tool taxonomy, base prompt, personality, mode delta, approval policy,## Context Management, compaction relay template) in one place, while keeping the runtime's dynamic assembly (environment, skills catalogue, instructions, memory, compaction-summary merge) intact.The composer receives a
#[non_exhaustive] StaticPromptCtxcarryingmode,approval_mode,model_id,allow_shell, anddefault_layers(what the default composition would have produced, for reference or partial reuse).Why — the gap between existing mechanisms
set_base_prompt_override, locale, authority recap)Op::SyncSessionsystem_prompt_overriderefresh_system_promptshort-circuits entirely: the embedder forfeits environment, skills, instructions, memory, and compaction-summary merge, and must re-implement half of prompts.rsThis hook fills the middle tier, and is the logical completion of the per-block override family that landed in v0.8.49.
Trust-boundary note (prompts content)
This PR does not change any prompt content. With the hook unset, output is byte-identical to current main — covered by a dedicated test (
static_prompt_composer_unset_keeps_default_layers_byte_identical). The change is mechanism-only: anOnceLockhook in the same style as the existingset_*_overridefamily, plus twois_none()gates so an installed composer also owns the Context Management and compaction-template appends.Embedder evolution stays available:
ctx.default_layershands the full default composition to the composer, so an embedder can diff/reuse upstream improvements rather than being frozen out of them.Scope
StaticPromptCtx, gating of the two static appends, tests.Tests / local commands run
cargo fmt— cleancargo clippy -p codewhale-tui— no warnings from this changecargo test --workspace --all-features— 4609 passed, 0 failedOnceLock) to avoid poisoning process-global state for sibling tests.Motivation context
We embed codewhale as a library inside a Tauri GUI product targeting a small local model (Qwen3.6-35B on vLLM). The default static doctrine is written for the terminal TUI + DeepSeek V4 (approval walkthroughs, sub-agent fan-out advice,
/compact+ prompt-cache guidance) — much of it doesn't apply in an embedded GUI and measurably distracts a 35B model. Per-block hooks got us partway (thanks for landing those!), but every upstream release that adds a static block leaks it into our prompt; this hook closes that gap for any embedder.🤖 Generated with Claude Code
Greptile Summary
Adds
set_static_prompt_composer_override— a new opt-in hook that lets embedders replace all compile-time static prompt layers in one callback, sitting between the existing per-block overrides and the fullsystem_prompt_override. The runtime dynamic assembly (environment, skills, instructions, memory, compaction-summary merge) is preserved unchanged; only the static doctrine surface is handed to the composer.#[non_exhaustive] StaticPromptCtxcarriesmode,approval_mode,model_id,allow_shell, anddefault_layersto the composer, withOnceLockregistration consistent with the existingset_*_overridefamily.system_prompt_for_mode_with_context_skills_session_and_approvalare suppressed viastatic_prompt_composer().is_none()guards, avoiding duplicate static content.apply_static_prompt_composerdirectly, and a byte-identity test confirms zero output change when no composer is set.Confidence Score: 4/5
Safe to merge: the default code path is byte-identical to main (verified by a dedicated test), and the new hook is only active when an embedder explicitly calls set_static_prompt_composer_override.
The refactoring is clean and the byte-identity test gives strong confidence that unset behavior is unchanged. The two findings are both API design concerns: personality is missing from StaticPromptCtx (embedders must string-scan default_layers to detect it), and the registration function returns an opaque unit error instead of giving back the rejected closure, inconsistent with the rest of the override family. Neither affects the default code path or the correctness of the composer mechanism.
crates/tui/src/prompts.rs — the StaticPromptCtx struct and set_static_prompt_composer_override signature.
Important Files Changed
Reviews (1): Last reviewed commit: "feat(prompts): add static prompt compose..." | Re-trigger Greptile