Skip to content

Commit 93d7510

Browse files
authored
Merge pull request #13 from PTFOPlayer/feat/batched-tool-results
Feat/batched tool results
2 parents 7fbed62 + 7463241 commit 93d7510

13 files changed

Lines changed: 269 additions & 64 deletions

File tree

src/agent/mod.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,11 @@ pub async fn run_agent_loop(
419419
let mut is_error = false;
420420
let mut was_interrupted = false;
421421

422+
// Thinking chain tracking: accumulate thinking delta and track
423+
// whether we've shown the header, so we only print new content.
424+
let mut thinking_content = String::new();
425+
let mut thinking_header_shown = false;
426+
422427
// Spinner state: animate while waiting for the first content chunk
423428
let mut spinner_idx: usize = 0;
424429
let mut waiting_for_first_chunk = true;
@@ -450,6 +455,32 @@ pub async fn run_agent_loop(
450455
is_error = true;
451456
}
452457

458+
// Display thinking/reasoning chain if enabled and present
459+
if let Some(ref thinking) = msg.message.thinking
460+
&& ctx.show_thinking
461+
&& !thinking.is_empty()
462+
{
463+
// Clear spinner before first output (thinking or content)
464+
if waiting_for_first_chunk && has_shown_spinner {
465+
write!(stdout, "\r{CLEAR_LINE}")?;
466+
stdout.flush()?;
467+
waiting_for_first_chunk = false;
468+
} else {
469+
waiting_for_first_chunk = false;
470+
}
471+
472+
// Show [thinking] header once, before the first delta
473+
if !thinking_header_shown {
474+
write!(stdout, "{DIM}{THINK_COLOR}[thinking] ")?;
475+
thinking_header_shown = true;
476+
}
477+
478+
// Each chunk's `thinking` is a delta — print only the new part
479+
write!(stdout, "{thinking}")?;
480+
thinking_content.push_str(thinking);
481+
stdout.flush()?;
482+
}
483+
453484
if !msg.message.content.is_empty() {
454485
// Clear spinner before first content
455486
if waiting_for_first_chunk && has_shown_spinner {
@@ -461,6 +492,12 @@ pub async fn run_agent_loop(
461492
waiting_for_first_chunk = false;
462493
}
463494

495+
// Transition from thinking to content: close styling
496+
if thinking_header_shown {
497+
writeln!(stdout, "{RESET}")?;
498+
thinking_header_shown = false;
499+
}
500+
464501
response_content.push_str(&msg.message.content);
465502
stdout.write_all(msg.message.content.as_bytes())?;
466503
stdout.flush()?;
@@ -505,6 +542,12 @@ pub async fn run_agent_loop(
505542
stdout.flush()?;
506543
}
507544

545+
// Close thinking styling if stream ended while still in thinking mode
546+
if thinking_header_shown {
547+
writeln!(stdout, "{RESET}")?;
548+
stdout.flush()?;
549+
}
550+
508551
// Handle user interrupt (Ctrl+C during generation)
509552
if was_interrupted {
510553
interrupted.store(false, Ordering::SeqCst);

src/agent/tools.rs

Lines changed: 100 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,20 @@ use crate::ui::confirm::Confirmation;
1818

1919
use super::safety::is_safe_command;
2020

21+
/// Result from executing a generic tool call.
22+
struct GenericToolResult {
23+
/// Formatted content for batching into the conversation message.
24+
content: String,
25+
/// If this was an auditable tool (run/write/edit), the tool name.
26+
audit_tool_name: Option<String>,
27+
/// For auditable tools: the primary argument (command for "run", path for "write"/"edit").
28+
audit_detail: Option<String>,
29+
/// Duration of the tool execution in milliseconds.
30+
duration_ms: u64,
31+
/// Whether the tool returned an error.
32+
is_error: bool,
33+
}
34+
2135
/// Handle tool calls from the assistant response.
2236
///
2337
/// Returns `Ok(true)` if tool results were added to messages (the caller should
@@ -53,6 +67,9 @@ pub async fn handle_tool_calls<W: Write>(
5367
});
5468
session.append_message(messages.last().expect("just pushed a message"));
5569

70+
// Collect generic tool results to batch them into a single message
71+
let mut generic_tool_results: Vec<GenericToolResult> = Vec::new();
72+
5673
for call in tool_calls {
5774
// Check for interrupt between tool calls
5875
if interrupted.load(std::sync::atomic::Ordering::SeqCst) {
@@ -156,8 +173,51 @@ pub async fn handle_tool_calls<W: Write>(
156173
continue;
157174
}
158175

159-
// Generic tool execution
160-
execute_generic_tool(call, tool_manager, messages, session, stdout, auto_accepted).await;
176+
// Generic tool execution — collect result for batching
177+
let result = execute_generic_tool(call, tool_manager, stdout, auto_accepted).await;
178+
179+
// Log to audit if this was an auditable tool (run/write/edit)
180+
if let Some(ref tool_name) = result.audit_tool_name {
181+
let exit_code = if result.is_error { -1 } else { 0 };
182+
let session_id = session.id().to_string();
183+
crate::commands::audit::log_command(
184+
&session_id,
185+
tool_name,
186+
result.audit_detail.as_deref().unwrap_or(""),
187+
exit_code,
188+
auto_accepted,
189+
result.duration_ms,
190+
);
191+
}
192+
193+
generic_tool_results.push(result);
194+
}
195+
196+
// Batch all generic tool results into a single message
197+
if !generic_tool_results.is_empty() {
198+
let batched_content = if generic_tool_results.len() == 1 {
199+
generic_tool_results.into_iter().next().unwrap().content
200+
} else {
201+
format!(
202+
"Multiple tool results ({} total):\n\n{}",
203+
generic_tool_results.len(),
204+
generic_tool_results
205+
.into_iter()
206+
.map(|r| r.content)
207+
.collect::<Vec<_>>()
208+
.join("\n\n---\n\n")
209+
)
210+
};
211+
212+
messages.push(Message {
213+
role: Role::Tool,
214+
content: format!(
215+
"Tool results:\n{}\n\nUse these results to continue helping the user.",
216+
batched_content
217+
),
218+
tool_calls: vec![],
219+
});
220+
session.append_message(messages.last().expect("just pushed a message"));
161221
}
162222

163223
Ok(true)
@@ -244,11 +304,9 @@ fn format_duration(ms: u64) -> String {
244304
async fn execute_generic_tool<W: Write>(
245305
call: &ToolCall,
246306
tool_manager: &ToolManager,
247-
messages: &mut Vec<Message>,
248-
session: &mut Session,
249307
stdout: &mut W,
250308
auto_accepted: bool,
251-
) {
309+
) -> GenericToolResult {
252310
// Show the "Executing..." header line
253311
if auto_accepted {
254312
if call.function.name == "run" {
@@ -334,25 +392,6 @@ async fn execute_generic_tool<W: Write>(
334392

335393
let duration_ms = start_time.elapsed().as_millis() as u64;
336394

337-
// Log to audit if this is a "run" command
338-
if call.function.name == "run"
339-
&& let Some(cmd) = call
340-
.function
341-
.arguments
342-
.get("command")
343-
.and_then(|v| v.as_str())
344-
{
345-
let exit_code = if result.starts_with("Error:") { -1 } else { 0 };
346-
let session_id = session.id().to_string();
347-
crate::commands::audit::log_command(
348-
&session_id,
349-
cmd,
350-
exit_code,
351-
auto_accepted,
352-
duration_ms,
353-
);
354-
}
355-
356395
// For tools that return potentially large listings, show only a summary
357396
match call.function.name.as_str() {
358397
"read" => {
@@ -441,15 +480,44 @@ async fn execute_generic_tool<W: Write>(
441480
}
442481
writeln!(stdout, "{RESET}").unwrap();
443482
stdout.flush().unwrap();
444-
messages.push(Message {
445-
role: Role::Tool,
446-
content: format!(
447-
"Tool '{}' result:\n{}\n\nUse this result to continue helping the user.",
448-
call.function.name, result
483+
484+
// Capture audit-relevant info before returning
485+
let (audit_tool_name, audit_detail) = match call.function.name.as_str() {
486+
"run" => (
487+
Some("run".to_string()),
488+
call.function
489+
.arguments
490+
.get("command")
491+
.and_then(|v| v.as_str())
492+
.map(|s| s.to_string()),
449493
),
450-
tool_calls: vec![],
451-
});
452-
session.append_message(messages.last().expect("just pushed a message"));
494+
"write" => (
495+
Some("write".to_string()),
496+
call.function
497+
.arguments
498+
.get("path")
499+
.and_then(|v| v.as_str())
500+
.map(|s| s.to_string()),
501+
),
502+
"edit" => (
503+
Some("edit".to_string()),
504+
call.function
505+
.arguments
506+
.get("path")
507+
.and_then(|v| v.as_str())
508+
.map(|s| s.to_string()),
509+
),
510+
_ => (None, None),
511+
};
512+
let is_error = result.starts_with("Error:");
513+
514+
GenericToolResult {
515+
content: format!("### {} Tool Result\n\n{}", call.function.name, result),
516+
audit_tool_name,
517+
audit_detail,
518+
duration_ms,
519+
is_error,
520+
}
453521
}
454522

455523
/// Handle the switch_mode signal: update context and system prompt.

src/commands/audit.rs

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ pub struct AuditEntry {
1818
pub timestamp: u64,
1919
/// Session ID where the command was run.
2020
pub session_id: String,
21-
/// The command that was executed.
21+
/// The tool that was executed (e.g. "run", "write", "edit").
22+
#[serde(default = "default_tool_name")]
23+
pub tool_name: String,
24+
/// The primary argument: shell command for "run", file path for "write"/"edit".
2225
pub command: String,
2326
/// Exit code of the command (0 = success).
2427
pub exit_code: i32,
@@ -28,6 +31,10 @@ pub struct AuditEntry {
2831
pub duration_ms: u64,
2932
}
3033

34+
fn default_tool_name() -> String {
35+
"run".to_string()
36+
}
37+
3138
// ── Audit log path ──────────────────────────────────────────────────────────
3239

3340
/// Get the default audit log path: ~/.local/share/tinyharness/audit.jsonl
@@ -50,6 +57,7 @@ pub fn ensure_audit_dir() -> std::io::Result<()> {
5057
/// Append a command execution to the audit log.
5158
pub fn log_command(
5259
session_id: &str,
60+
tool_name: &str,
5361
command: &str,
5462
exit_code: i32,
5563
auto_accepted: bool,
@@ -60,6 +68,7 @@ pub fn log_command(
6068
let entry = AuditEntry {
6169
timestamp: now_timestamp(),
6270
session_id: session_id.to_string(),
71+
tool_name: tool_name.to_string(),
6372
command: command.to_string(),
6473
exit_code,
6574
auto_accepted,
@@ -150,14 +159,15 @@ pub fn show_last(n: usize) {
150159

151160
// Header
152161
println!(
153-
" {}{:20} {:30} {:6} {:8} {:10}{}",
154-
BOLD, "Timestamp", "Command", "Exit", "Auto?", "Duration", RESET
162+
" {}{:20} {:6} {:26} {:6} {:8} {:10}{}",
163+
BOLD, "Timestamp", "Tool", "Command", "Exit", "Auto?", "Duration", RESET
155164
);
156165
println!(
157-
" {}{:20} {:30} {:6} {:8} {:10}{}",
166+
" {}{:20} {:6} {:26} {:6} {:8} {:10}{}",
158167
GRAY,
159168
"────────────────────",
160-
"──────────────────────────────",
169+
"──────",
170+
"──────────────────────────",
161171
"──────",
162172
"────────",
163173
"──────────",
@@ -166,8 +176,13 @@ pub fn show_last(n: usize) {
166176

167177
for entry in &entries {
168178
let ts_str = format_timestamp(entry.timestamp);
169-
let cmd_display = if entry.command.len() > 30 {
170-
format!("{}...", &entry.command[..27])
179+
let tool_display = if entry.tool_name.len() > 6 {
180+
format!("{}...", &entry.tool_name[..3])
181+
} else {
182+
entry.tool_name.clone()
183+
};
184+
let cmd_display = if entry.command.len() > 26 {
185+
format!("{}...", &entry.command[..23])
171186
} else {
172187
entry.command.clone()
173188
};
@@ -196,8 +211,8 @@ pub fn show_last(n: usize) {
196211
};
197212

198213
println!(
199-
" {}{} {} {} {} {}",
200-
GRAY, ts_str, cmd_display, exit_str, auto_str, duration_str
214+
" {}{} {}{}{} {} {} {} {}",
215+
GRAY, ts_str, CYAN, tool_display, RESET, cmd_display, exit_str, auto_str, duration_str
201216
);
202217
}
203218

@@ -220,14 +235,15 @@ pub fn show_session(session_id: &str) {
220235

221236
// Header
222237
println!(
223-
" {}{:20} {:30} {:6} {:8} {:10}{}",
224-
BOLD, "Timestamp", "Command", "Exit", "Auto?", "Duration", RESET
238+
" {}{:20} {:6} {:26} {:6} {:8} {:10}{}",
239+
BOLD, "Timestamp", "Tool", "Command", "Exit", "Auto?", "Duration", RESET
225240
);
226241
println!(
227-
" {}{:20} {:30} {:6} {:8} {:10}{}",
242+
" {}{:20} {:6} {:26} {:6} {:8} {:10}{}",
228243
GRAY,
229244
"────────────────────",
230-
"──────────────────────────────",
245+
"──────",
246+
"──────────────────────────",
231247
"──────",
232248
"────────",
233249
"──────────",
@@ -236,8 +252,13 @@ pub fn show_session(session_id: &str) {
236252

237253
for entry in &entries {
238254
let ts_str = format_timestamp(entry.timestamp);
239-
let cmd_display = if entry.command.len() > 30 {
240-
format!("{}...", &entry.command[..27])
255+
let tool_display = if entry.tool_name.len() > 6 {
256+
format!("{}...", &entry.tool_name[..3])
257+
} else {
258+
entry.tool_name.clone()
259+
};
260+
let cmd_display = if entry.command.len() > 26 {
261+
format!("{}...", &entry.command[..23])
241262
} else {
242263
entry.command.clone()
243264
};
@@ -266,8 +287,8 @@ pub fn show_session(session_id: &str) {
266287
};
267288

268289
println!(
269-
" {}{} {} {} {} {}",
270-
GRAY, ts_str, cmd_display, exit_str, auto_str, duration_str
290+
" {}{} {}{}{} {} {} {} {}",
291+
GRAY, ts_str, CYAN, tool_display, RESET, cmd_display, exit_str, auto_str, duration_str
271292
);
272293
}
273294

0 commit comments

Comments
 (0)