fix(tui): hide shell prompt guidance when shell is disabled

Thread allow_shell into system prompt composition and remove shell-only guidance
  when shell tools are not available.

  This keeps the prompt aligned with the runtime tool catalog and prevents the
  model from trying exec_shell or task_shell_* after allow_shell = false.
This commit is contained in:
reidliu41
2026-06-03 20:26:37 +08:00
committed by Hunter Bown
parent dba332e8d5
commit 195dd6b9ab
3 changed files with 164 additions and 4 deletions
+2
View File
@@ -631,6 +631,7 @@ impl Engine {
translation_enabled: config.translation_enabled,
model_id: &config.model,
show_thinking: config.show_thinking,
allow_shell: config.allow_shell,
},
session.approval_mode,
);
@@ -2363,6 +2364,7 @@ In {new} mode: {policy}\n\n\
translation_enabled: self.config.translation_enabled,
model_id: &self.config.model,
show_thinking: self.config.show_thinking,
allow_shell: self.session.allow_shell,
},
self.session.approval_mode,
);
+161 -4
View File
@@ -38,6 +38,10 @@ pub struct PromptSessionContext<'a> {
/// When false, the prompt should not spend localization pressure on
/// `reasoning_content` the user will never see.
pub show_thinking: bool,
/// Whether shell tools are available in the runtime tool catalog for
/// this session. The prompt must not advertise shell-only workflows
/// when runtime gates have removed those tools.
pub allow_shell: bool,
}
impl Default for PromptSessionContext<'_> {
@@ -50,6 +54,7 @@ impl Default for PromptSessionContext<'_> {
translation_enabled: false,
model_id: "codewhale",
show_thinking: true,
allow_shell: true,
}
}
}
@@ -775,9 +780,24 @@ pub fn compose_prompt_with_approval_and_model(
personality: Personality,
approval_mode: ApprovalMode,
model_id: &str,
) -> String {
compose_prompt_with_approval_model_and_shell(mode, personality, approval_mode, model_id, true)
}
fn compose_prompt_with_approval_model_and_shell(
mode: AppMode,
personality: Personality,
approval_mode: ApprovalMode,
model_id: &str,
allow_shell: bool,
) -> String {
let tool_taxonomy = render_core_tool_taxonomy_block(mode);
let base_prompt = apply_model_template(effective_base_prompt().trim(), model_id);
let shell_tools_available = allow_shell && mode != AppMode::Plan;
let base_prompt = render_base_prompt_for_tool_availability(
effective_base_prompt().trim(),
model_id,
shell_tools_available,
);
let parts: [&str; 5] = [
tool_taxonomy.as_str(),
base_prompt.as_str(),
@@ -798,6 +818,66 @@ pub fn compose_prompt_with_approval_and_model(
out
}
fn render_base_prompt_for_tool_availability(
prompt: &str,
model_id: &str,
shell_tools_available: bool,
) -> String {
let prompt = if shell_tools_available {
prompt.to_string()
} else {
remove_shell_tool_guidance(prompt)
};
apply_model_template(&prompt, model_id)
}
fn remove_shell_tool_guidance(prompt: &str) -> String {
let prompt = prompt
.lines()
.filter(|line| !is_shell_disabled_prompt_line(line))
.collect::<Vec<_>>()
.join("\n");
let prompt = remove_markdown_section(&prompt, "### `exec_shell`");
let prompt = prompt.replace(
"; for GitHub issue/PR/release triage, prefer the native `gh ... --json` CLI through shell because it is authenticated, structured, and reproducible; `github_issue_context` / `github_pr_context` are read-only fallbacks when the CLI route is unavailable;",
"; for GitHub issue/PR/release triage, use `github_issue_context` / `github_pr_context` as read-only routes when shell tools are unavailable;",
);
prompt.replace(
"Use deterministic Python inside RLM for exact counts and structured aggregation; use `grep_files` or `exec_shell` directly when that is the clearest deterministic check.",
"Use deterministic Python inside RLM for exact counts and structured aggregation; use `grep_files` directly when that is the clearest deterministic check.",
)
}
fn is_shell_disabled_prompt_line(line: &str) -> bool {
line.starts_with("- Arithmetic, math, calculations → `exec_shell`")
|| line.starts_with("- Hashes, encodings, checksums → `exec_shell`")
|| line.starts_with("- Current time, date, timezone → `exec_shell`")
|| line
.starts_with("- System state: OS, CPU, memory, disk, ports, processes → `exec_shell`")
|| line.starts_with("- **Shell**:")
}
fn remove_markdown_section(prompt: &str, heading: &str) -> String {
let Some(start) = prompt.find(heading) else {
return prompt.to_string();
};
let after_heading = start + heading.len();
let end = prompt[after_heading..]
.find("\n### ")
.map(|offset| after_heading + offset)
.unwrap_or(prompt.len());
let before = prompt[..start].trim_end();
let after = prompt[end..].trim_start_matches('\n');
if before.is_empty() {
after.to_string()
} else if after.is_empty() {
before.to_string()
} else {
format!("{before}\n\n{after}")
}
}
/// Compose for the default personality (Calm).
fn compose_mode_prompt(mode: AppMode) -> String {
compose_prompt(mode, Personality::Calm)
@@ -812,7 +892,13 @@ fn compose_mode_prompt_with_approval_and_model(
approval_mode: ApprovalMode,
model_id: &str,
) -> String {
compose_prompt_with_approval_and_model(mode, Personality::Calm, approval_mode, model_id)
compose_prompt_with_approval_model_and_shell(
mode,
Personality::Calm,
approval_mode,
model_id,
true,
)
}
// ── Public API ────────────────────────────────────────────────────────
@@ -885,6 +971,7 @@ pub fn system_prompt_for_mode_with_context_and_skills(
translation_enabled: false,
model_id: "codewhale",
show_thinking: true,
allow_shell: true,
},
)
}
@@ -917,8 +1004,13 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval(
session_context: PromptSessionContext<'_>,
approval_mode: ApprovalMode,
) -> SystemPrompt {
let mode_prompt =
compose_mode_prompt_with_approval_and_model(mode, approval_mode, session_context.model_id);
let mode_prompt = compose_prompt_with_approval_model_and_shell(
mode,
Personality::Calm,
approval_mode,
session_context.model_id,
session_context.allow_shell,
);
// Load project context from workspace
let project_context = load_project_context_with_parents(workspace);
@@ -1253,6 +1345,60 @@ mod tests {
);
}
#[test]
fn composed_prompt_keeps_shell_guidance_when_shell_tools_are_available() {
let prompt = compose_prompt_with_approval_model_and_shell(
AppMode::Agent,
Personality::Calm,
ApprovalMode::Suggest,
"deepseek-v4-pro",
true,
);
assert!(prompt.contains("- **Shell**:"));
assert!(prompt.contains("### `exec_shell`"));
assert!(prompt.contains("`task_shell_start`"));
assert!(prompt.contains("Arithmetic, math, calculations → `exec_shell`"));
}
#[test]
fn composed_prompt_omits_shell_guidance_when_shell_tools_are_unavailable() {
let prompt = compose_prompt_with_approval_model_and_shell(
AppMode::Agent,
Personality::Calm,
ApprovalMode::Suggest,
"deepseek-v4-pro",
false,
);
for shell_only in [
"- **Shell**:",
"### `exec_shell`",
"`task_shell_start`",
"exec_shell",
"task_shell",
"Arithmetic, math, calculations → `exec_shell`",
"Hashes, encodings, checksums → `exec_shell`",
"Current time, date, timezone → `exec_shell`",
"System state: OS, CPU, memory, disk, ports, processes → `exec_shell`",
"CLI through shell",
"or `exec_shell` directly",
] {
assert!(
!prompt.contains(shell_only),
"shell-disabled prompt must not advertise {shell_only:?}"
);
}
assert!(
prompt.contains("actual runtime gates still determine what tools can execute"),
"shell-disabled prompt should keep the runtime-gates hierarchy clause"
);
assert!(
prompt.contains("`task_gate_run`") && prompt.contains("`github_issue_context`"),
"shell-disabled prompt should keep non-shell task evidence tools"
);
}
#[test]
fn composed_prompt_starts_with_core_tool_taxonomy() {
let prompt = compose_prompt_with_approval_and_model(
@@ -1456,6 +1602,7 @@ mod tests {
translation_enabled: false,
model_id: "codewhale",
show_thinking: true,
allow_shell: true,
},
ApprovalMode::Suggest,
) {
@@ -1527,6 +1674,7 @@ mod tests {
translation_enabled: false,
model_id: "codewhale",
show_thinking: true,
allow_shell: true,
},
ApprovalMode::Suggest,
) {
@@ -1571,6 +1719,7 @@ mod tests {
translation_enabled: false,
model_id: "codewhale",
show_thinking: false,
allow_shell: true,
},
ApprovalMode::Suggest,
) {
@@ -1625,6 +1774,7 @@ mod tests {
translation_enabled: false,
model_id: "codewhale",
show_thinking: true,
allow_shell: true,
},
ApprovalMode::Suggest,
) {
@@ -1730,6 +1880,7 @@ mod tests {
translation_enabled: false,
model_id: "codewhale",
show_thinking: true,
allow_shell: true,
},
) {
SystemPrompt::Text(text) => text,
@@ -1767,6 +1918,7 @@ mod tests {
translation_enabled: false,
model_id: "codewhale",
show_thinking: true,
allow_shell: true,
},
) {
SystemPrompt::Text(text) => text,
@@ -1796,6 +1948,7 @@ mod tests {
translation_enabled: false,
model_id: "codewhale",
show_thinking: true,
allow_shell: true,
},
) {
SystemPrompt::Text(text) => text,
@@ -1854,6 +2007,7 @@ mod tests {
translation_enabled: false,
model_id: "codewhale",
show_thinking: true,
allow_shell: true,
},
) {
SystemPrompt::Text(text) => text,
@@ -1883,6 +2037,7 @@ mod tests {
translation_enabled: false,
model_id: "codewhale",
show_thinking: true,
allow_shell: true,
},
) {
SystemPrompt::Text(text) => text,
@@ -2079,6 +2234,7 @@ mod tests {
translation_enabled: false,
model_id: "codewhale",
show_thinking: true,
allow_shell: true,
},
) {
SystemPrompt::Text(text) => text,
@@ -2114,6 +2270,7 @@ mod tests {
translation_enabled: false,
model_id: "codewhale",
show_thinking: true,
allow_shell: true,
},
) {
SystemPrompt::Text(text) => text,
+1
View File
@@ -4860,6 +4860,7 @@ async fn dispatch_user_message(
translation_enabled: app.translation_enabled,
model_id: &app.model,
show_thinking: app.show_thinking,
allow_shell: app.allow_shell,
},
),
);