Skip to content

feat(prompts): add static prompt composer override for embedders#2786

Open
h3c-hexin wants to merge 1 commit into
Hmbown:mainfrom
h3c-hexin:pr/static-prompt-composer
Open

feat(prompts): add static prompt composer override for embedders#2786
h3c-hexin wants to merge 1 commit into
Hmbown:mainfrom
h3c-hexin:pr/static-prompt-composer

Conversation

@h3c-hexin
Copy link
Copy Markdown
Contributor

@h3c-hexin h3c-hexin commented Jun 5, 2026

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] StaticPromptCtx carrying mode, approval_mode, model_id, allow_shell, and default_layers (what the default composition would have produced, for reference or partial reuse).

Why — the gap between existing mechanisms

mechanism granularity problem for embedders
per-block hooks (set_base_prompt_override, locale, authority recap) one constant any newly added static block still lands in the embedder's prompt unseen — per-block hooks can't seal the static surface
Op::SyncSession system_prompt_override whole prompt refresh_system_prompt short-circuits entirely: the embedder forfeits environment, skills, instructions, memory, and compaction-summary merge, and must re-implement half of prompts.rs

This 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: an OnceLock hook in the same style as the existing set_*_override family, plus two is_none() gates so an installed composer also owns the Context Management and compaction-template appends.

Embedder evolution stays available: ctx.default_layers hands the full default composition to the composer, so an embedder can diff/reuse upstream improvements rather than being frozen out of them.

Scope

  • In scope: the hook, StaticPromptCtx, gating of the two static appends, tests.
  • Out of scope: any prompt wording changes; any new prompt blocks; per-block hook behavior (unchanged).

Tests / local commands run

  • cargo fmt — clean
  • cargo clippy -p codewhale-tui — no warnings from this change
  • cargo test --workspace --all-features — 4609 passed, 0 failed
  • New tests inject the composer as a parameter (not the global OnceLock) 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 full system_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.

  • A #[non_exhaustive] StaticPromptCtx carries mode, approval_mode, model_id, allow_shell, and default_layers to the composer, with OnceLock registration consistent with the existing set_*_override family.
  • When a composer is installed, sections 4 (Context Management) and 5 (COMPACT_TEMPLATE) in system_prompt_for_mode_with_context_skills_session_and_approval are suppressed via static_prompt_composer().is_none() guards, avoiding duplicate static content.
  • Tests avoid global OnceLock poisoning by injecting the composer through apply_static_prompt_composer directly, 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

Filename Overview
crates/tui/src/prompts.rs New StaticPromptComposer hook and StaticPromptCtx struct added; compose_prompt_with_approval_model_and_shell refactored into compose_default_static_layers + apply_static_prompt_composer; two is_none() gates added in system_prompt_for_mode_with_context_skills_session_and_approval. Two minor API design gaps: personality absent from StaticPromptCtx, and the registration function returns Result<(), ()> inconsistently with the rest of the override family.

Fix All in Codex Fix All in Claude Code Fix All in Cursor

Reviews (1): Last reviewed commit: "feat(prompts): add static prompt compose..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

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.
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 5, 2026

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 .github/APPROVED_CONTRIBUTORS will be closed automatically.

Please read CONTRIBUTING.md for the expected contribution shape. A maintainer can grant PR access by commenting /lgtm on a pull request.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

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.

Comment thread crates/tui/src/prompts.rs
Comment on lines +372 to +384
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,
}
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

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.

Suggested change
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,
}

Comment thread crates/tui/src/prompts.rs
Comment on lines +844 to +878
) -> 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,
}
}
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

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,
    }
}

Comment thread crates/tui/src/prompts.rs
Comment on lines +1354 to +1389
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(),
);
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

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(),
        );

Comment thread crates/tui/src/prompts.rs
Comment on lines +397 to +399
pub fn set_static_prompt_composer_override(f: Box<StaticPromptComposer>) -> Result<(), ()> {
STATIC_PROMPT_COMPOSER.set(f).map_err(|_| ())
}
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.

P2 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.

Suggested change
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)
}

Fix in Codex Fix in Claude Code Fix in Cursor

Comment thread crates/tui/src/prompts.rs
Comment on lines +375 to +383
/// 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,
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.

P2 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".

Fix in Codex Fix in Claude Code Fix in Cursor

@Hmbown
Copy link
Copy Markdown
Owner

Hmbown commented Jun 5, 2026

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:

  1. StaticPromptCtx should expose personality, since personality directly shapes the default static layers.
  2. set_static_prompt_composer_override should return the rejected composer or another useful error instead of Result<(), ()>.
  3. Please either include the default Context Management / Compaction Relay static tail in the composer context, or narrow the docs/contract so embedders know default_layers is only the early mode prompt and those later static blocks are composer-owned but not provided for reuse.

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: cargo fmt --all -- --check, cargo test -p codewhale-tui prompts::tests:: -- --nocapture, cargo check -p codewhale-tui --all-features --locked, plus the full PR CI matrix.

Thanks again for pushing this embedder customization path forward.

@Hmbown
Copy link
Copy Markdown
Owner

Hmbown commented Jun 5, 2026

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:

  • StaticPromptCtx should include the stable static layers that are still part of the system prompt after feat(cache): project mode prompts per request #2801, including personality;
  • the override contract should not claim ownership of mode, approval, or tool taxonomy now that those are request-time runtime metadata rather than static prompt text;
  • set_static_prompt_composer_override should return useful state when rejecting a second composer instead of Result<(), ()>;
  • the Context Management / compaction relay behavior should either remain in the default static composition path or be exposed deliberately so embedders do not accidentally drop it.

Suggested verification once updated: cargo fmt --all -- --check, cargo test -p codewhale-tui prompts::tests:: -- --nocapture, cargo test -p codewhale-tui --locked --bin codewhale-tui runtime_prompt -- --nocapture, and cargo check -p codewhale-tui --all-features --locked.

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.

Hmbown added a commit that referenced this pull request Jun 6, 2026
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>
@Hmbown
Copy link
Copy Markdown
Owner

Hmbown commented Jun 6, 2026

Thanks again @h3c-hexin. I harvested the release-safe v0.9 slice of this into #2819, now merged into codex/v0.9.0-stewardship.

What landed is intentionally narrower than this branch: embedders can replace the byte-stable base/personality prompt segment through set_static_prompt_composer_override, with StaticPromptCtx carrying model_id, personality, shell availability, and the reusable default layers. Mode metadata, approval policy, tool taxonomy, Context Management, and the Compaction Relay stay under CodeWhale runtime prompt assembly so the #2801 static-vs-runtime boundary and compaction safeguards remain intact.

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:

  • cargo fmt --all -- --check
  • git diff --check
  • ./scripts/release/check-versions.sh
  • cmp -s CHANGELOG.md crates/tui/CHANGELOG.md
  • cargo test -p codewhale-tui prompts::tests:: -- --nocapture
  • cargo test -p codewhale-tui --locked --bin codewhale-tui runtime_prompt -- --nocapture
  • cargo check -p codewhale-tui --all-features --locked

And #2762 is green again after the merge. Appreciate you pushing this embedder customization path forward.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants