Skip to content

Commit 0d409a9

Browse files
author
z27014
committed
fix: align shell guidance with execution
Signed-off-by: z27014 <zhuo.wenpei@h3c.com>
1 parent f853f8f commit 0d409a9

3 files changed

Lines changed: 205 additions & 2 deletions

File tree

crates/tui/src/tools/shell.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ use windows::core::PCWSTR;
3939
#[cfg(not(target_env = "ohos"))]
4040
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
4141

42+
mod guidance;
4243
mod output;
4344

4445
use super::shell_output::{summarize_output, truncate_with_meta};
@@ -3405,7 +3406,7 @@ impl ToolSpec for BashTool {
34053406
if self.read_only {
34063407
"Inspect the workspace with the bounded read-only command subset. Commands run directly as argv, never through a shell; only action=run plus command, cwd, and timeout_ms are accepted."
34073408
} else {
3408-
"Execute a shell command in the workspace. Action \"run\" (default) executes a command; \"wait\" polls a background task; \"interact\" sends stdin to a background task; \"cancel\" kills a background task. Foreground mode is for bounded commands; use background=true for work expected to take >5 seconds. Commands run via the user's login shell ($SHELL); when that shell is zsh, a bare word starting with `=` undergoes `=command` PATH expansion (e.g. `echo ===` fails) — quote such arguments, e.g. `echo '==='`."
3409+
guidance::description()
34093410
}
34103411
}
34113412

@@ -3423,7 +3424,7 @@ impl ToolSpec for BashTool {
34233424
},
34243425
"command": {
34253426
"type": "string",
3426-
"description": "The shell command to execute (action=run)"
3427+
"description": guidance::runtime_command_guidance()
34273428
},
34283429
"timeout_ms": {
34293430
"type": "integer",
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
//! Model-facing command syntax follows the same dispatcher as execution.
2+
3+
use crate::shell_dispatcher::{ShellKind, global_dispatcher};
4+
use std::sync::OnceLock;
5+
6+
const POWERSHELL_GUIDANCE: &str = "Use PowerShell syntax. Bash is a legacy tool name, not a Bash interpreter. \
7+
For JSON use Invoke-RestMethod; for text use Invoke-WebRequest -UseBasicParsing \
8+
on Windows PowerShell to avoid dependency on the Internet Explorer engine. \
9+
Do not assume head, sed, awk, or other Unix utilities are installed. \
10+
Use PowerShell cmdlets or verified available programs; parse JSON and select needed fields \
11+
instead of appending head. Bash heredocs are not PowerShell syntax. Use PowerShell \
12+
5.1-compatible syntax (no && or ||) unless the detected executable is pwsh. Example: \
13+
$text = 'sample'; $text.Substring(0, [Math]::Min(3, $text.Length)).";
14+
15+
const BASH_GUIDANCE: &str = "Use Bash syntax: pipelines, redirections, $(command), \
16+
and && / || are supported. Quote paths and variable expansions, such as \"$path\"; \
17+
use single quotes for literal text. For literal multiline input, use a quoted heredoc \
18+
delimiter (<<'EOF') with its closing delimiter on a separate line. Use only installed \
19+
programs; do not assume GNU-specific flags on macOS/BSD. Example: printf '%s\\n' 'sample'.";
20+
21+
const SH_GUIDANCE: &str = "Use POSIX sh syntax: pipelines, redirections, $(command), \
22+
and && / || are supported. Quote paths and variable expansions, such as \"$path\"; \
23+
use single quotes for literal text. Do not use Bash-only arrays, [[ ... ]], \
24+
process substitution, or here-strings. Use only installed programs and portable \
25+
utility options. Example: printf '%s\\n' 'sample'.";
26+
27+
const ZSH_GUIDANCE: &str = "Use zsh syntax. Quote paths, literal wildcard patterns, \
28+
and variable expansions; unmatched unquoted globs can fail before a command runs. \
29+
A bare word starting with = undergoes =command PATH expansion (e.g. echo === fails); \
30+
quote such arguments, e.g. echo '==='. Do not assume Bash array indexing or word \
31+
splitting rules. Use only installed programs; do not assume GNU-specific flags on macOS/BSD.";
32+
33+
const CMD_GUIDANCE: &str = "Use cmd.exe syntax: %NAME% expands environment variables; use double quotes \
34+
around paths containing spaces (single quotes are not quoting delimiters). \
35+
Use cmd built-ins or installed programs, not Bash or PowerShell syntax. \
36+
Do not assume Unix utilities are installed. Example: echo sample";
37+
38+
const FISH_GUIDANCE: &str = "Use fish syntax: set NAME value for variables, \
39+
and begin ... end for blocks. Bash assignment NAME=value and \
40+
heredocs are not portable fish syntax. Quote paths and use only \
41+
installed programs. Example: printf '%s\\n' 'sample'.";
42+
43+
const FALLBACK_GUIDANCE: &str = "Use the detected shell's syntax and only installed programs; \
44+
do not infer Bash syntax from the legacy tool name.";
45+
46+
pub(super) fn command_guidance(kind: &ShellKind) -> String {
47+
let syntax = match kind {
48+
// Match execution's PowerShell-family detection, including custom paths.
49+
_ if kind.is_powershell() => POWERSHELL_GUIDANCE,
50+
ShellKind::Cmd => CMD_GUIDANCE,
51+
ShellKind::Sh => SH_GUIDANCE,
52+
ShellKind::Bash => BASH_GUIDANCE,
53+
ShellKind::Custom { binary, .. } => {
54+
match std::path::Path::new(binary)
55+
.file_stem()
56+
.and_then(|name| name.to_str())
57+
.map(str::to_ascii_lowercase)
58+
.as_deref()
59+
{
60+
Some("bash") => BASH_GUIDANCE,
61+
Some("sh" | "dash" | "ash") => SH_GUIDANCE,
62+
Some("zsh") => ZSH_GUIDANCE,
63+
Some("fish") => FISH_GUIDANCE,
64+
_ => FALLBACK_GUIDANCE,
65+
}
66+
}
67+
_ => FALLBACK_GUIDANCE,
68+
};
69+
format!(
70+
"The command to execute (action=run). Actual execution shell: `{}`. {syntax}",
71+
kind.binary()
72+
)
73+
}
74+
75+
pub(super) fn runtime_command_guidance() -> &'static str {
76+
static GUIDANCE: OnceLock<String> = OnceLock::new();
77+
GUIDANCE.get_or_init(|| command_guidance(global_dispatcher().kind()))
78+
}
79+
80+
pub(super) fn description() -> &'static str {
81+
static DESCRIPTION: OnceLock<String> = OnceLock::new();
82+
DESCRIPTION.get_or_init(|| {
83+
format!(
84+
"{} Execute in the workspace. Action \"run\" (default) executes a command; \
85+
\"wait\" polls a background task; \"interact\" sends stdin to a background task; \
86+
\"cancel\" kills a background task. Foreground mode is for bounded commands; \
87+
use background=true for work expected to take >5 seconds.",
88+
runtime_command_guidance()
89+
)
90+
})
91+
}
92+
93+
#[cfg(test)]
94+
mod tests {
95+
use super::*;
96+
97+
#[test]
98+
fn shell_guidance_preserves_unix_shell_contracts() {
99+
for (binary, expected) in [
100+
("/bin/bash", BASH_GUIDANCE),
101+
("bash", BASH_GUIDANCE),
102+
("/usr/local/bin/bash", BASH_GUIDANCE),
103+
("/bin/sh", SH_GUIDANCE),
104+
("/bin/dash", SH_GUIDANCE),
105+
("/bin/ash", SH_GUIDANCE),
106+
("/bin/zsh", ZSH_GUIDANCE),
107+
] {
108+
let text = command_guidance(&ShellKind::Custom {
109+
binary: binary.into(),
110+
flag: "-lc".into(),
111+
});
112+
assert!(text.contains(expected), "missing guidance for {binary}");
113+
assert!(!text.contains("Use PowerShell syntax"));
114+
}
115+
assert!(command_guidance(&ShellKind::Bash).contains(BASH_GUIDANCE));
116+
assert!(command_guidance(&ShellKind::Sh).contains(SH_GUIDANCE));
117+
}
118+
119+
#[test]
120+
fn shell_guidance_matches_each_interpreter() {
121+
for kind in [
122+
ShellKind::Pwsh,
123+
ShellKind::WindowsPowerShell,
124+
ShellKind::Cmd,
125+
ShellKind::Sh,
126+
ShellKind::Bash,
127+
ShellKind::Custom {
128+
binary: "/bin/zsh".into(),
129+
flag: "-lc".into(),
130+
},
131+
ShellKind::Custom {
132+
binary: "/opt/pwsh".into(),
133+
flag: "-c".into(),
134+
},
135+
ShellKind::Custom {
136+
binary: "/bin/fish".into(),
137+
flag: "-c".into(),
138+
},
139+
] {
140+
let text = command_guidance(&kind);
141+
assert!(text.contains(kind.binary()));
142+
assert_eq!(text.contains("Use PowerShell syntax"), kind.is_powershell());
143+
assert_eq!(
144+
text.contains("=command PATH expansion"),
145+
kind.binary() == "/bin/zsh"
146+
);
147+
assert!(!text.contains("user's login shell"));
148+
}
149+
}
150+
}

crates/tui/src/tools/shell/tests.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,58 @@ fn env_lock() -> &'static Mutex<()> {
2121

2222
const BACKGROUND_COMPLETION_WAIT_MS: u64 = 30_000;
2323

24+
#[test]
25+
fn forkguard_shell_catalog_guidance_matches_execution() {
26+
let tool = BashTool::new("Bash");
27+
let schema = tool.input_schema();
28+
let command = schema["properties"]["command"]["description"]
29+
.as_str()
30+
.unwrap();
31+
let dispatcher = crate::shell_dispatcher::global_dispatcher();
32+
assert!(command.contains(dispatcher.kind().binary()));
33+
assert!(tool.description().contains(command));
34+
assert_eq!(tool.name(), "Bash");
35+
assert!(tool.model_visible());
36+
assert!(tool.description().contains("background=true"));
37+
let readonly = BashTool::read_only("Bash");
38+
assert!(readonly.description().contains("never through a shell"));
39+
assert!(
40+
!readonly
41+
.input_schema()
42+
.to_string()
43+
.contains("Actual execution shell")
44+
);
45+
let alias = BashTool::alias("exec_shell", "run");
46+
assert_eq!(alias.description(), tool.description());
47+
let workspace = tempdir().unwrap();
48+
let mut registry = crate::tools::ToolRegistry::new(ToolContext::new(workspace.path()));
49+
registry.register(std::sync::Arc::new(BashTool::new("Bash")));
50+
let catalog = registry.to_api_tools();
51+
assert_eq!(catalog.len(), 1);
52+
assert_eq!(catalog[0].description, tool.description());
53+
assert_eq!(
54+
catalog[0].input_schema["properties"]["command"]["description"],
55+
command
56+
);
57+
}
58+
59+
#[test]
60+
#[ignore = "Exports model-visible shell fixtures for opt-in live model evaluation"]
61+
fn export_shell_guidance_eval_fixture() {
62+
let path = std::env::var_os("SHELL_GUIDANCE_FIXTURE").expect("SHELL_GUIDANCE_FIXTURE");
63+
let tool = BashTool::new("Bash");
64+
let mut schema = tool.input_schema();
65+
crate::tools::schema_sanitize::sanitize(&mut schema);
66+
crate::tools::schema_canonicalize::canonicalize_schema(&mut schema);
67+
let fixture = json!({
68+
"name": tool.name(),
69+
"description": tool.description(),
70+
"input_schema": schema,
71+
"shell": crate::shell_dispatcher::global_dispatcher().kind().binary(),
72+
});
73+
std::fs::write(path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap();
74+
}
75+
2476
#[test]
2577
fn deleted_saved_workspace_reports_path_and_recovery_before_spawn() {
2678
let workspace = tempdir().expect("workspace");

0 commit comments

Comments
 (0)