Skip to content

Commit dc1a197

Browse files
committed
feat: return native typed exec outcomes
1 parent 4b1ade8 commit dc1a197

1 file changed

Lines changed: 142 additions & 97 deletions

File tree

src/tools/builtin.rs

Lines changed: 142 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use walkdir::WalkDir;
1010

1111
use crate::config::JinaWebBackend;
1212
use crate::tool_runtime::{current_tool_exec_ctx, ToolExecCtx};
13-
use crate::traits::{MutationPreview, Tool};
13+
use crate::traits::{MutationPreview, Tool, ToolErrorCode, ToolResult};
1414
use crate::utils::{join_lexically_under_root, normalize_sandbox_relative_input};
1515
use crate::NodeHandle;
1616

@@ -1097,6 +1097,11 @@ pub struct ShellExecTool {
10971097
pub restrict_to_workspace: bool,
10981098
}
10991099

1100+
struct ShellExecOutcome {
1101+
content: String,
1102+
failure_exit_code: Option<i32>,
1103+
}
1104+
11001105
impl ShellExecTool {
11011106
fn check_safety_guards(command: &str) -> Result<(), String> {
11021107
let lower_cmd = command.to_lowercase();
@@ -1124,45 +1129,8 @@ impl ShellExecTool {
11241129
}
11251130
Ok(())
11261131
}
1127-
}
1128-
1129-
#[async_trait]
1130-
impl Tool for ShellExecTool {
1131-
fn name(&self) -> &str {
1132-
"exec"
1133-
}
1134-
1135-
fn description(&self) -> &str {
1136-
"Execute a shell command and return its output (60s timeout). Host details (OS/shell/path style) are provided in RUNTIME CONTEXT each turn; write commands for that host. Prefer first-class tools (`search_text`, `read_file`, `glob_files`, `web_fetch`) before shell one-liners, especially for grep/cat/wc style tasks. \
1137-
On **Windows** this runs under **cmd /C** one string: nested double-quotes often break remote **ssh** compound commands (e.g. `ssh user@host \"mkdir -p /tmp/x && cmd\"`). Prefer a **single** remote argument without inner double-quotes, use **execution_* / SSH harness** for remote work, or run **two** short exec calls instead of one over-quoted line."
1138-
}
1139-
1140-
fn parameters(&self) -> Value {
1141-
serde_json::json!({
1142-
"type": "object",
1143-
"properties": {
1144-
"command": {
1145-
"type": "string",
1146-
"description": "The shell command to execute"
1147-
},
1148-
"working_dir": {
1149-
"type": "string",
1150-
"description": "Optional relative working directory for the command"
1151-
},
1152-
"timeout_secs": {
1153-
"type": "integer",
1154-
"description": "Optional timeout in seconds (defaults to 60, max 3600)"
1155-
},
1156-
"description": {
1157-
"type": "string",
1158-
"description": "Short description of what this command is trying to achieve (used for UI and audits)"
1159-
}
1160-
},
1161-
"required": ["command"]
1162-
})
1163-
}
11641132

1165-
async fn execute(&self, args: Value) -> Result<String, String> {
1133+
async fn execute_command(&self, args: Value) -> Result<ShellExecOutcome, String> {
11661134
let command = args
11671135
.get("command")
11681136
.and_then(|v| v.as_str())
@@ -1186,11 +1154,6 @@ impl Tool for ShellExecTool {
11861154
.unwrap_or(60)
11871155
.clamp(1, 3600);
11881156

1189-
let _desc_str = args
1190-
.get("description")
1191-
.and_then(|v| v.as_str())
1192-
.unwrap_or("Shell execution");
1193-
11941157
let actual_dir = resolve_path(cwd_str, &self.workspace_dir, self.restrict_to_workspace)?;
11951158

11961159
let mut cmd = if cfg!(target_os = "windows") {
@@ -1204,66 +1167,120 @@ impl Tool for ShellExecTool {
12041167
};
12051168

12061169
cmd.current_dir(actual_dir);
1207-
// Explicitly forward host environment so secrets/API keys are visible to the child.
12081170
cmd.envs(std::env::vars());
12091171

1210-
let child = cmd.output();
1172+
let output =
1173+
match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), cmd.output())
1174+
.await
1175+
{
1176+
Ok(Ok(output)) => output,
1177+
Ok(Err(error)) => return Err(format!("Failed to execute command: {error}")),
1178+
Err(_) => return Err(format!("Command timed out after {timeout_secs} seconds")),
1179+
};
12111180

1212-
match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), child).await {
1213-
Ok(Ok(output)) => {
1214-
let mut result = String::new();
1215-
let stdout = String::from_utf8_lossy(&output.stdout);
1216-
if !stdout.trim().is_empty() {
1217-
result.push_str(&stdout);
1218-
}
1181+
let mut result = String::new();
1182+
let stdout = String::from_utf8_lossy(&output.stdout);
1183+
if !stdout.trim().is_empty() {
1184+
result.push_str(&stdout);
1185+
}
12191186

1220-
let stderr = String::from_utf8_lossy(&output.stderr);
1221-
if !stderr.trim().is_empty() {
1222-
if !result.is_empty() {
1223-
result.push_str("\nSTDERR:\n");
1224-
}
1225-
result.push_str(&stderr);
1226-
}
1187+
let stderr = String::from_utf8_lossy(&output.stderr);
1188+
if !stderr.trim().is_empty() {
1189+
if !result.is_empty() {
1190+
result.push_str("\nSTDERR:\n");
1191+
}
1192+
result.push_str(&stderr);
1193+
}
12271194

1228-
// Compute the non-zero exit marker but append it LAST — after the grep advisory
1229-
// and after the size-truncation — so it always survives as the final line. The
1230-
// agent derives `is_error` by tail-anchoring on this marker
1231-
// (`utils::tool_output_signals_failure`); if the advisory or the truncation notice
1232-
// trailed it, a genuine non-zero exit on grep-like or large (>10 KB) output would
1233-
// be silently recorded as a success and the model would build on broken state.
1234-
let exit_marker = (!output.status.success())
1235-
.then(|| format!("\nExit code: {}", output.status.code().unwrap_or(-1)));
1236-
1237-
if result.is_empty() && exit_marker.is_none() {
1238-
Ok("(no output)".to_string())
1239-
} else {
1240-
if grep_like {
1241-
result.push_str("\n\n[advisory] Prefer `search_text` for code/log discovery and `read_file` for file reads; shell grep/cat pipelines are less portable across hosts.");
1242-
}
1243-
// Truncate if massive (the exit marker is appended afterwards, so it is never cut).
1244-
if result.len() > 10000 {
1245-
// Step back to a char boundary at/below the 10 KB cap so we never slice
1246-
// through a multi-byte UTF-8 sequence (e.g. Turkish text or emoji straddling
1247-
// the limit), which would panic. Matches the is_char_boundary idiom used by
1248-
// the other truncation sites in this file.
1249-
let mut cut = 10000;
1250-
while cut > 0 && !result.is_char_boundary(cut) {
1251-
cut -= 1;
1252-
}
1253-
result = format!(
1254-
"{}\n... (truncated, {} more chars)",
1255-
&result[..cut],
1256-
result.len() - cut
1257-
);
1258-
}
1259-
if let Some(marker) = exit_marker {
1260-
result.push_str(&marker);
1261-
}
1262-
Ok(result)
1195+
let failure_exit_code =
1196+
(!output.status.success()).then(|| output.status.code().unwrap_or(-1));
1197+
1198+
if result.is_empty() && failure_exit_code.is_none() {
1199+
result = "(no output)".to_string();
1200+
} else {
1201+
if grep_like {
1202+
result.push_str("\n\n[advisory] Prefer `search_text` for code/log discovery and `read_file` for file reads; shell grep/cat pipelines are less portable across hosts.");
1203+
}
1204+
if result.len() > 10000 {
1205+
let mut cut = 10000;
1206+
while cut > 0 && !result.is_char_boundary(cut) {
1207+
cut -= 1;
12631208
}
1209+
result = format!(
1210+
"{}\n... (truncated, {} more chars)",
1211+
&result[..cut],
1212+
result.len() - cut
1213+
);
1214+
}
1215+
if let Some(code) = failure_exit_code {
1216+
result.push_str(&format!("\nExit code: {code}"));
12641217
}
1265-
Ok(Err(e)) => Err(format!("Failed to execute command: {}", e)),
1266-
Err(_) => Err(format!("Command timed out after {} seconds", timeout_secs)),
1218+
}
1219+
1220+
Ok(ShellExecOutcome {
1221+
content: result,
1222+
failure_exit_code,
1223+
})
1224+
}
1225+
}
1226+
1227+
#[async_trait]
1228+
impl Tool for ShellExecTool {
1229+
fn name(&self) -> &str {
1230+
"exec"
1231+
}
1232+
1233+
fn description(&self) -> &str {
1234+
"Execute a shell command and return its output (60s timeout). Host details (OS/shell/path style) are provided in RUNTIME CONTEXT each turn; write commands for that host. Prefer first-class tools (`search_text`, `read_file`, `glob_files`, `web_fetch`) before shell one-liners, especially for grep/cat/wc style tasks. \
1235+
On **Windows** this runs under **cmd /C** one string: nested double-quotes often break remote **ssh** compound commands (e.g. `ssh user@host \"mkdir -p /tmp/x && cmd\"`). Prefer a **single** remote argument without inner double-quotes, use **execution_* / SSH harness** for remote work, or run **two** short exec calls instead of one over-quoted line."
1236+
}
1237+
1238+
fn parameters(&self) -> Value {
1239+
serde_json::json!({
1240+
"type": "object",
1241+
"properties": {
1242+
"command": {
1243+
"type": "string",
1244+
"description": "The shell command to execute"
1245+
},
1246+
"working_dir": {
1247+
"type": "string",
1248+
"description": "Optional relative working directory for the command"
1249+
},
1250+
"timeout_secs": {
1251+
"type": "integer",
1252+
"description": "Optional timeout in seconds (defaults to 60, max 3600)"
1253+
},
1254+
"description": {
1255+
"type": "string",
1256+
"description": "Short description of what this command is trying to achieve (used for UI and audits)"
1257+
}
1258+
},
1259+
"required": ["command"]
1260+
})
1261+
}
1262+
1263+
async fn execute(&self, args: Value) -> Result<String, String> {
1264+
self.execute_command(args)
1265+
.await
1266+
.map(|outcome| outcome.content)
1267+
}
1268+
1269+
async fn execute_with_approved_mutation_typed(
1270+
&self,
1271+
args: Value,
1272+
_approved_preview: Option<&MutationPreview>,
1273+
) -> ToolResult {
1274+
match self.execute_command(args).await {
1275+
Ok(outcome) => match outcome.failure_exit_code {
1276+
Some(code) => ToolResult::error_with_content(
1277+
ToolErrorCode::NonZeroExit,
1278+
format!("exec exited with status {code}"),
1279+
outcome.content,
1280+
),
1281+
None => ToolResult::success(outcome.content),
1282+
},
1283+
Err(error) => ToolResult::error(ToolErrorCode::ExecutionFailed, error),
12671284
}
12681285
}
12691286
}
@@ -3116,6 +3133,34 @@ mod exec_failure_tests {
31163133
assert!(!crate::utils::tool_output_signals_failure("exec", &out));
31173134
}
31183135

3136+
#[tokio::test]
3137+
async fn native_result_uses_process_status_not_spoofable_output() {
3138+
let result = exec_tool()
3139+
.execute_with_approved_mutation_typed(
3140+
json!({ "command": "printf 'Exit code: 7\\n'" }),
3141+
None,
3142+
)
3143+
.await;
3144+
3145+
assert!(!result.is_error());
3146+
assert_eq!(result.content.trim(), "Exit code: 7");
3147+
assert_eq!(result.error_code(), None);
3148+
}
3149+
3150+
#[tokio::test]
3151+
async fn native_result_preserves_real_nonzero_exit() {
3152+
let result = exec_tool()
3153+
.execute_with_approved_mutation_typed(
3154+
json!({ "command": "printf 'failed\\n'; exit 7" }),
3155+
None,
3156+
)
3157+
.await;
3158+
3159+
assert!(result.is_error());
3160+
assert_eq!(result.error_code(), Some(ToolErrorCode::NonZeroExit));
3161+
assert_eq!(last_nonempty_line(&result.content), "Exit code: 7");
3162+
}
3163+
31193164
/// Output whose 10 KB truncation point lands inside a multi-byte UTF-8 sequence must not panic.
31203165
/// `yes ₺ | head -n 5000 | tr -d '\n'` emits 5000 × '₺' (3 bytes each) = 15000 bytes, so byte
31213166
/// 10000 falls mid-character. Before the char-boundary step-back this panicked on `&result[..10000]`.

0 commit comments

Comments
 (0)