Skip to content

Commit c306fab

Browse files
committed
Feat: Support streaming of execute bash commands
1 parent e977cfd commit c306fab

File tree

4 files changed

+106
-74
lines changed

4 files changed

+106
-74
lines changed

crates/q_cli/src/cli/chat/conversation_state.rs

Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ use tracing::{
3232
};
3333

3434
use super::tools::ToolSpec;
35+
use super::truncate_safe;
3536
use crate::cli::chat::tools::{
3637
InputSchema,
3738
InvokeOutput,
@@ -86,7 +87,7 @@ impl ConversationState {
8687
self.history.clear();
8788
}
8889

89-
pub async fn append_new_user_message(&mut self, input: String) {
90+
pub fn append_new_user_message(&mut self, input: String) {
9091
debug_assert!(self.next_message.is_none(), "next_message should not exist");
9192
if let Some(next_message) = self.next_message.as_ref() {
9293
warn!(?next_message, "next_message should not exist");
@@ -338,24 +339,6 @@ fn build_shell_state() -> ShellState {
338339
}
339340
}
340341

341-
fn truncate_safe(s: &str, max_bytes: usize) -> &str {
342-
if s.len() <= max_bytes {
343-
return s;
344-
}
345-
346-
let mut byte_count = 0;
347-
let mut char_indices = s.char_indices();
348-
349-
for (byte_idx, _) in &mut char_indices {
350-
if byte_count + (byte_idx - byte_count) > max_bytes {
351-
break;
352-
}
353-
byte_count = byte_idx;
354-
}
355-
356-
&s[..byte_count]
357-
}
358-
359342
#[cfg(test)]
360343
mod tests {
361344
use fig_api_client::model::{
@@ -388,7 +371,7 @@ mod tests {
388371

389372
// First, build a large conversation history. We need to ensure that the order is always
390373
// User -> Assistant -> User -> Assistant ...and so on.
391-
conversation_state.append_new_user_message("start".to_string()).await;
374+
conversation_state.append_new_user_message("start".to_string());
392375
for i in 0..=100 {
393376
let s = conversation_state.as_sendable_conversation_state();
394377
assert!(
@@ -408,7 +391,7 @@ mod tests {
408391
content: i.to_string(),
409392
tool_uses: None,
410393
});
411-
conversation_state.append_new_user_message(i.to_string()).await;
394+
conversation_state.append_new_user_message(i.to_string());
412395
}
413396

414397
let s = conversation_state.as_sendable_conversation_state();
@@ -441,7 +424,7 @@ mod tests {
441424
let mut conversation_state = ConversationState::new(load_tools().unwrap());
442425

443426
// Build a long conversation history of tool use results.
444-
conversation_state.append_new_user_message("start".to_string()).await;
427+
conversation_state.append_new_user_message("start".to_string());
445428
for i in 0..=(MAX_CONVERSATION_STATE_HISTORY_LEN + 100) {
446429
let _ = conversation_state.as_sendable_conversation_state();
447430
conversation_state.push_assistant_message(AssistantResponseMessage {

crates/q_cli/src/cli/chat/mod.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,7 @@ Hi, I'm <g>Amazon Q</g>. Ask me anything.
464464
if !tool_uses.is_empty() {
465465
self.conversation_state.abandon_tool_use(tool_uses, user_input);
466466
} else {
467-
self.conversation_state.append_new_user_message(user_input).await;
467+
self.conversation_state.append_new_user_message(user_input);
468468
}
469469

470470
self.send_tool_use_telemetry().await;
@@ -874,6 +874,24 @@ Hi, I'm <g>Amazon Q</g>. Ask me anything.
874874
}
875875
}
876876

877+
pub fn truncate_safe(s: &str, max_bytes: usize) -> &str {
878+
if s.len() <= max_bytes {
879+
return s;
880+
}
881+
882+
let mut byte_count = 0;
883+
let mut char_indices = s.char_indices();
884+
885+
for (byte_idx, _) in &mut char_indices {
886+
if byte_count + (byte_idx - byte_count) > max_bytes {
887+
break;
888+
}
889+
byte_count = byte_idx;
890+
}
891+
892+
&s[..byte_count]
893+
}
894+
877895
#[derive(Debug)]
878896
struct ToolUseEventBuilder {
879897
pub conversation_id: String,

crates/q_cli/src/cli/chat/tools/execute_bash.rs

Lines changed: 81 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1+
use std::collections::VecDeque;
12
use std::io::Write;
23
use std::process::Stdio;
34

4-
use bstr::ByteSlice;
55
use crossterm::style::{
66
self,
77
Color,
@@ -16,77 +16,113 @@ use eyre::{
1616
};
1717
use fig_os_shim::Context;
1818
use serde::Deserialize;
19+
use tokio::io::AsyncBufReadExt;
20+
use tokio::select;
21+
use tracing::error;
1922

2023
use super::{
2124
InvokeOutput,
2225
MAX_TOOL_RESPONSE_SIZE,
2326
OutputKind,
2427
};
28+
use crate::cli::chat::truncate_safe;
2529

2630
#[derive(Debug, Clone, Deserialize)]
2731
pub struct ExecuteBash {
2832
pub command: String,
29-
pub interactive: Option<bool>,
3033
}
3134

3235
impl ExecuteBash {
3336
pub async fn invoke(&self, mut updates: impl Write) -> Result<InvokeOutput> {
34-
queue!(
37+
execute!(
3538
updates,
3639
style::SetForegroundColor(Color::Green),
3740
style::Print(format!("Executing `{}`", &self.command)),
3841
style::ResetColor,
3942
style::Print("\n"),
4043
)?;
4144

42-
let (stdout, stderr) = match self.interactive {
43-
Some(true) => (Stdio::inherit(), Stdio::inherit()),
44-
_ => (Stdio::piped(), Stdio::piped()),
45-
};
46-
47-
let output = tokio::process::Command::new("bash")
45+
// We need to maintain a handle on stderr and stdout, but pipe it to the terminal as well
46+
let mut child = tokio::process::Command::new("bash")
4847
.arg("-c")
4948
.arg(&self.command)
5049
.stdin(Stdio::inherit())
51-
.stdout(stdout)
52-
.stderr(stderr)
50+
.stdout(Stdio::piped())
51+
.stderr(Stdio::piped())
5352
.spawn()
54-
.wrap_err_with(|| format!("Unable to spawn command '{}'", &self.command))?
55-
.wait_with_output()
56-
.await
57-
.wrap_err_with(|| format!("Unable to wait on subprocess for command '{}'", &self.command))?;
58-
let status = output.status.code().unwrap_or(0).to_string();
59-
let stdout = output.stdout.to_str_lossy();
60-
let stderr = output.stderr.to_str_lossy();
61-
62-
if let Some(false) = self.interactive {
63-
execute!(updates, style::Print(&stdout))?;
53+
.wrap_err_with(|| format!("Unable to spawn command '{}'", &self.command))?;
54+
55+
let stdout = child.stdout.take().unwrap();
56+
let stdout = tokio::io::BufReader::new(stdout);
57+
let mut stdout = stdout.lines();
58+
59+
let stderr = child.stderr.take().unwrap();
60+
let stderr = tokio::io::BufReader::new(stderr);
61+
let mut stderr = stderr.lines();
62+
63+
const LINE_COUNT: usize = 1024;
64+
let mut stdout_buf = VecDeque::with_capacity(LINE_COUNT);
65+
let mut stderr_buf = VecDeque::with_capacity(LINE_COUNT);
66+
67+
let exit_status = loop {
68+
child.stdin.take();
69+
70+
select! {
71+
biased;
72+
line = stdout.next_line() => match line {
73+
Ok(Some(line)) => {
74+
writeln!(updates, "{line}")?;
75+
if stdout_buf.len() >= LINE_COUNT {
76+
stdout_buf.pop_front();
77+
}
78+
stdout_buf.push_back(line);
79+
},
80+
Ok(None) => {},
81+
Err(err) => error!(%err, "Failed to read stdout of child process"),
82+
},
83+
line = stderr.next_line() => match line {
84+
Ok(Some(line)) => {
85+
writeln!(updates, "{line}")?;
86+
if stderr_buf.len() >= LINE_COUNT {
87+
stderr_buf.pop_front();
88+
}
89+
stderr_buf.push_back(line);
90+
},
91+
Ok(None) => {},
92+
Err(err) => error!(%err, "Failed to read stderr of child process"),
93+
},
94+
exit_status = child.wait() => {
95+
break exit_status;
96+
},
97+
};
6498
}
99+
.wrap_err_with(|| format!("No exit status for '{}'", &self.command))?;
65100

66-
let stdout = format!(
67-
"{}{}",
68-
&stdout[0..stdout.len().min(MAX_TOOL_RESPONSE_SIZE / 3)],
69-
if stdout.len() > MAX_TOOL_RESPONSE_SIZE / 3 {
70-
" ... truncated"
71-
} else {
72-
""
73-
}
74-
);
75-
76-
let stderr = format!(
77-
"{}{}",
78-
&stderr[0..stderr.len().min(MAX_TOOL_RESPONSE_SIZE / 3)],
79-
if stderr.len() > MAX_TOOL_RESPONSE_SIZE / 3 {
80-
" ... truncated"
81-
} else {
82-
""
83-
}
84-
);
101+
updates.flush()?;
102+
103+
let stdout = stdout_buf.into_iter().collect::<Vec<_>>().join("\n");
104+
let stderr = stderr_buf.into_iter().collect::<Vec<_>>().join("\n");
85105

86106
let output = serde_json::json!({
87-
"exit_status": status,
88-
"stdout": stdout,
89-
"stderr": stderr,
107+
"exit_status": exit_status.code().unwrap_or(0).to_string(),
108+
"stdout": format!(
109+
"{}{}",
110+
truncate_safe(&stdout, MAX_TOOL_RESPONSE_SIZE / 3),
111+
if stdout.len() > MAX_TOOL_RESPONSE_SIZE / 3 {
112+
" ... truncated"
113+
} else {
114+
""
115+
}
116+
),
117+
"stderr": format!(
118+
"{}{}",
119+
truncate_safe(&stderr, MAX_TOOL_RESPONSE_SIZE / 3),
120+
if stderr.len() > MAX_TOOL_RESPONSE_SIZE / 3 {
121+
" ... truncated"
122+
} else {
123+
""
124+
}
125+
),
90126
});
91127

92128
Ok(InvokeOutput {
@@ -127,7 +163,6 @@ mod tests {
127163
// Verifying stdout
128164
let v = serde_json::json!({
129165
"command": "echo Hello, world!",
130-
"interactive": false
131166
});
132167
let out = serde_json::from_value::<ExecuteBash>(v)
133168
.unwrap()
@@ -137,7 +172,7 @@ mod tests {
137172

138173
if let OutputKind::Json(json) = out.output {
139174
assert_eq!(json.get("exit_status").unwrap(), &0.to_string());
140-
assert_eq!(json.get("stdout").unwrap(), "Hello, world!\n");
175+
assert_eq!(json.get("stdout").unwrap(), "Hello, world!");
141176
assert_eq!(json.get("stderr").unwrap(), "");
142177
} else {
143178
panic!("Expected JSON output");
@@ -157,7 +192,7 @@ mod tests {
157192
if let OutputKind::Json(json) = out.output {
158193
assert_eq!(json.get("exit_status").unwrap(), &0.to_string());
159194
assert_eq!(json.get("stdout").unwrap(), "");
160-
assert_eq!(json.get("stderr").unwrap(), "Hello, world!\n");
195+
assert_eq!(json.get("stderr").unwrap(), "Hello, world!");
161196
} else {
162197
panic!("Expected JSON output");
163198
}

crates/q_cli/src/cli/chat/tools/tool_index.json

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,9 @@
88
"command": {
99
"type": "string",
1010
"description": "Bash command to execute"
11-
},
12-
"interactive": {
13-
"type": "boolean",
14-
"description": "Whether or not the command is interactive. Interactive commands like nano will overtake our conversation until exited. On exit, they will have produced no stderr or stdout."
1511
}
1612
},
17-
"required": ["command", "interactive"]
13+
"required": ["command"]
1814
}
1915
},
2016
"fs_read": {

0 commit comments

Comments
 (0)