use crate::requests::system_content::{CollectToolResultParams, collect_tool_result};
use crate::sessions::{RequestContext, SessionCommand, SessionState, turn_for_client};
use crate::tools::context::ToolContext;
use crate::tools::{
PreparedImage, STREAMING_CHANNEL_CAPACITY, ToolError, ToolOutput, ToolOutputFormat,
ToolRegistry, sanitize_transcript, truncate_tool_output,
};
use choreo_ai_protocols::{ChatToolCall, ToolResultItem};
use choreo_keystore::ServiceCredential;
use choreo_proto::{DaemonMessage, DisplayedImageRecord, ImageMetadata, SessionEvent, TokenUsage};
pub(crate) const TOOL_TIMEOUT_GRACE: Duration = Duration::from_secs(5);
const IMAGE_TIMEOUT_HEADROOM_SECS: u64 = 60;
const IMAGE_TIMEOUT: Duration = Duration::from_secs(
(choreo_ai_protocols::images::IMAGE_MAX_ATTEMPTS as u64
+ choreo_ai_protocols::images::IMAGE_DOWNLOAD_ATTEMPTS as u64)
* choreo_ai_protocols::images::IMAGE_TOTAL_TIMEOUT_SECS
+ IMAGE_TIMEOUT_HEADROOM_SECS,
);
use std::collections::HashSet;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use std::time::Instant;
use tracing::{debug, warn};
pub(crate) fn broadcast_turn_appended(
cmd_tx: &mpsc::Sender<SessionCommand>,
session: &SessionState,
session_id: u64,
turn_id: u32,
) {
if let Some(turn) = session.turns.get(&turn_id)
&& let Err(e) = cmd_tx.send(SessionCommand::Broadcast(DaemonMessage::Session {
session_id: Some(session_id),
event: SessionEvent::TurnAppended {
turn_id,
turn: turn_for_client(turn),
},
}))
{
warn!(%turn_id, error = %e, "failed to broadcast TurnAppended");
}
}
pub(crate) fn emit_image(
cmd_tx: &mpsc::Sender<SessionCommand>,
image: PreparedImage,
tool_call_id: Option<String>,
session: &mut SessionState,
session_id: u64,
turn_id: u32,
) {
let record = DisplayedImageRecord {
metadata: ImageMetadata {
mime_type: image.mime_type,
width: image.width,
height: image.height,
byte_len: image.data.len() as u64,
alt: image.alt,
},
data: image.data,
tool_call_id,
};
session.add_displayed_image(turn_id, record.clone());
broadcast_turn_appended(cmd_tx, session, session_id, turn_id);
}
pub(crate) fn spawn_forwarding_thread(
cmd_tx: mpsc::Sender<SessionCommand>,
session_id: u64,
request_id: u32,
call_id: String,
output_rx: crossbeam_channel::Receiver<Vec<u8>>,
kill_rx: crossbeam_channel::Receiver<()>,
) -> std::thread::JoinHandle<()> {
thread::spawn(move || {
loop {
crossbeam_channel::select_biased! {
recv(output_rx) -> msg => match msg {
Ok(data) => {
if cmd_tx
.send(SessionCommand::Broadcast(DaemonMessage::Session {
session_id: Some(session_id),
event: SessionEvent::ToolResultChunk {
request_id,
call_id: call_id.clone(),
data,
},
}))
.is_err()
{
break;
}
if matches!(
kill_rx.try_recv(),
Ok(()) | Err(crossbeam_channel::TryRecvError::Disconnected)
) {
let drain_budget = output_rx.len();
for _ in 0..drain_budget {
let Ok(data) = output_rx.try_recv() else {
break;
};
if cmd_tx
.send(SessionCommand::Broadcast(
DaemonMessage::Session {
session_id: Some(session_id),
event: SessionEvent::ToolResultChunk {
request_id,
call_id: call_id.clone(),
data,
},
},
))
.is_err()
{
break;
}
}
break;
}
}
Err(_) => break,
},
recv(kill_rx) -> _ => break,
}
}
})
}
pub(crate) fn is_cancelled_once(rx: &crossbeam_channel::Receiver<()>) -> bool {
rx.try_recv().is_ok()
}
pub(crate) fn accumulate_token_usage(
session: &mut SessionState,
token_usage: &Option<TokenUsage>,
turn: u32,
ctx: &RequestContext,
) {
if let Some(u) = token_usage {
session.config.accumulated_usage.input_tokens += u.input_tokens;
session.config.accumulated_usage.output_tokens += u.output_tokens;
session.config.accumulated_usage.total_tokens += u.total_tokens;
session.config.last_prompt_tokens = Some(u.input_tokens);
debug!(
session_id = ctx.session_id,
turn,
input_tokens = u.input_tokens,
output_tokens = u.output_tokens,
total_tokens = u.total_tokens,
accumulated_input = session.config.accumulated_usage.input_tokens,
accumulated_output = session.config.accumulated_usage.output_tokens,
"accumulated token usage"
);
}
}
pub(crate) fn broadcast_token_usage(ctx: &RequestContext, session: &SessionState) {
let _ = ctx.cmd_tx.send(SessionCommand::SyncAccumulatedUsage {
token_usage: session.config.accumulated_usage,
last_prompt_tokens: session.config.last_prompt_tokens,
});
}
pub(crate) fn determine_tool_timeout(name: &str, arguments_json: &str) -> Option<Duration> {
if name == "spawn_subsession" {
return None;
}
let base = if matches!(name, "sh" | "nushell" | "fish" | "exec") {
Duration::from_secs(300)
} else if name == "generate_image" {
IMAGE_TIMEOUT
} else {
Duration::from_secs(60)
};
let requested = if matches!(name, "sh" | "nushell" | "fish" | "exec") {
serde_json::from_str::<serde_json::Value>(arguments_json)
.ok()
.and_then(|args| args.get("timeout").and_then(|t| t.as_u64()))
.filter(|ms| *ms > 0)
.map(Duration::from_millis)
} else {
None
};
let effective = requested
.map(|r| r + TOOL_TIMEOUT_GRACE)
.map_or(base, |raised| raised.max(base));
if effective > base {
debug!(
tool = name,
requested_ms = requested.map(|r| r.as_millis() as u64),
effective_secs = effective.as_secs(),
"outer tool deadline raised to cover the requested timeout"
);
}
Some(effective)
}
pub(crate) struct ToolHandle {
pub(crate) tool_call: ChatToolCall,
pub(crate) output: ToolOutput,
pub(crate) image: Option<PreparedImage>,
pub(crate) started_at: Instant,
}
pub(crate) struct SpawnToolArgs {
pub(crate) tool_call: ChatToolCall,
pub(crate) timeout: Option<Duration>,
pub(crate) request_id: u32,
pub(crate) session_id: u64,
pub(crate) registry: Arc<ToolRegistry>,
pub(crate) cmd_tx: mpsc::Sender<SessionCommand>,
pub(crate) x_credentials: Option<ServiceCredential>,
pub(crate) working_dir: Option<PathBuf>,
pub(crate) ctx: ToolContext,
pub(crate) invocation_description: String,
pub(crate) started_at: Instant,
pub(crate) result_tx: crossbeam_channel::Sender<ToolHandle>,
}
pub(crate) struct CallInfo {
pub(crate) call_id: String,
pub(crate) tool_name: String,
pub(crate) arguments_json: String,
pub(crate) invocation_description: String,
pub(crate) started_at: Instant,
pub(crate) kill_tx: crossbeam_channel::Sender<()>,
}
pub(crate) fn missing_calls<'a>(
call_infos: &'a [CallInfo],
delivered: &HashSet<String>,
) -> impl Iterator<Item = &'a CallInfo> {
call_infos
.iter()
.filter(move |info| !delivered.contains(&info.call_id))
}
pub(crate) fn panic_tool_handle(info: &CallInfo) -> ToolHandle {
ToolHandle {
tool_call: ChatToolCall {
id: info.call_id.clone(),
name: info.tool_name.clone(),
arguments_json: info.arguments_json.clone(),
caller: None,
},
output: ToolOutput {
content: "tool thread panicked".to_string(),
is_error: true,
invocation_description: info.invocation_description.clone(),
..Default::default()
},
image: None,
started_at: info.started_at,
}
}
pub(crate) struct SpawnedToolExecution {
exec_rx: crossbeam_channel::Receiver<Result<ToolOutput, ToolError>>,
kill_tx: crossbeam_channel::Sender<()>,
image_rx: mpsc::Receiver<PreparedImage>,
_forwarder: std::thread::JoinHandle<()>,
}
#[expect(clippy::too_many_arguments)]
pub(crate) fn spawn_tool_execution(
tool_call: &ChatToolCall,
format: ToolOutputFormat,
registry: Arc<ToolRegistry>,
x_credentials: Option<ServiceCredential>,
working_dir: Option<PathBuf>,
tool_ctx: ToolContext,
cmd_tx: mpsc::Sender<SessionCommand>,
session_id: u64,
request_id: u32,
) -> SpawnedToolExecution {
let (exec_tx, exec_rx) = crossbeam_channel::unbounded::<Result<ToolOutput, ToolError>>();
let (output_tx, output_rx) = crossbeam_channel::bounded::<Vec<u8>>(STREAMING_CHANNEL_CAPACITY);
let (kill_tx, kill_rx) = crossbeam_channel::unbounded::<()>();
let (image_tx, image_rx) = mpsc::channel::<PreparedImage>();
let _forwarder = spawn_forwarding_thread(
cmd_tx,
session_id,
request_id,
tool_call.id.clone(),
output_rx,
kill_rx,
);
let tc = tool_call.clone();
thread::spawn(move || {
let result = registry.execute_streaming_json(
&tc,
format,
output_tx,
x_credentials.as_ref(),
working_dir.as_deref(),
Some(&tool_ctx),
Some(image_tx),
);
let _ = exec_tx.send(result);
});
SpawnedToolExecution {
exec_rx,
kill_tx,
image_rx,
_forwarder,
}
}
pub(crate) fn spawn_single_tool(args: SpawnToolArgs) -> crossbeam_channel::Sender<()> {
let SpawnToolArgs {
tool_call,
timeout,
request_id,
session_id,
registry,
cmd_tx,
x_credentials,
working_dir,
ctx,
invocation_description,
started_at,
result_tx,
} = args;
let cancel_flag = Arc::clone(&ctx.cancelled);
let (tool_kill_tx, tool_kill_rx) = crossbeam_channel::unbounded::<()>();
let SpawnedToolExecution {
exec_rx,
kill_tx,
image_rx,
_forwarder,
} = spawn_tool_execution(
&tool_call,
ToolOutputFormat::Text,
registry,
x_credentials,
working_dir,
ctx,
cmd_tx,
session_id,
request_id,
);
let deadline = timeout.map(|d| Instant::now() + d);
thread::spawn(move || {
enum WaitOutcome {
Result(Result<Result<ToolOutput, ToolError>, crossbeam_channel::RecvError>),
Kill,
Deadline,
}
let outcome = match deadline {
None => crossbeam_channel::select_biased! {
recv(exec_rx) -> msg => WaitOutcome::Result(msg),
recv(tool_kill_rx) -> _ => WaitOutcome::Kill,
},
Some(deadline) => {
let remaining = deadline.saturating_duration_since(Instant::now());
crossbeam_channel::select_biased! {
recv(exec_rx) -> msg => WaitOutcome::Result(msg),
recv(tool_kill_rx) -> _ => WaitOutcome::Kill,
recv(crossbeam_channel::after(remaining)) -> _ => WaitOutcome::Deadline,
}
}
};
let output = match outcome {
WaitOutcome::Result(msg) => {
tool_result_from_channel(
&tool_call.name,
started_at,
msg,
&invocation_description,
false,
)
.0
}
WaitOutcome::Deadline => {
cancel_flag.store(true, Ordering::Relaxed);
drain_queued_or_synthesize(
&tool_call.name,
started_at,
&invocation_description,
&exec_rx,
format!(
"tool '{}' timed out after {}s",
tool_call.name,
timeout.unwrap_or(Duration::ZERO).as_secs(),
),
false,
)
.0
}
WaitOutcome::Kill => {
cancel_flag.store(true, Ordering::Relaxed);
let _ = kill_tx.send(());
let image = image_rx.try_recv().ok();
let content = format!("tool '{}' cancelled", tool_call.name);
crate::metrics::record_tool_execution(
&tool_call.name,
started_at.elapsed().as_secs_f64(),
true,
);
let _ = result_tx.send(ToolHandle {
tool_call,
output: ToolOutput {
content,
is_error: true,
invocation_description: invocation_description.clone(),
..Default::default()
},
image,
started_at,
});
return;
}
};
let image = image_rx.try_recv().ok();
let _ = kill_tx.send(());
let _ = result_tx.send(ToolHandle {
tool_call,
output,
image,
started_at,
});
});
tool_kill_tx
}
pub(crate) fn finalize_and_broadcast_turn(
session: &mut SessionState,
ctx: &RequestContext,
current_turn_id: u32,
) -> io::Result<()> {
session.finalize_turn(&ctx.db, ctx.session_id, current_turn_id)?;
if let Some(turn) = session.turns.get(¤t_turn_id) {
let _ = ctx
.cmd_tx
.send(SessionCommand::Broadcast(DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::TurnAppended {
turn_id: current_turn_id,
turn: turn_for_client(turn),
},
}));
}
Ok(())
}
pub(crate) fn finish_tool_call(
request_id: u32,
session: &mut SessionState,
tool_call: &ChatToolCall,
output: &mut ToolOutput,
ctx: &RequestContext,
turn_id: u32,
) {
let is_error = output.is_error;
let content = output.content.clone();
session.update_tool_result(turn_id, &tool_call.id, tool_call.name.clone(), output);
broadcast_turn_appended(&ctx.cmd_tx, session, ctx.session_id, turn_id);
let event = if is_error {
DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::ToolCallFailed {
request_id,
call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
error: content,
},
}
} else {
DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::ToolCallFinished {
request_id,
call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
},
}
};
if let Err(e) = ctx.cmd_tx.send(SessionCommand::Broadcast(event)) {
warn!(%request_id, error = %e, "failed to broadcast tool call finished/failed event");
}
}
#[expect(clippy::too_many_arguments)]
pub(crate) fn record_tool_completion(
request_id: u32,
session: &mut SessionState,
tool_call: &ChatToolCall,
output: &mut ToolOutput,
image: Option<PreparedImage>,
ctx: &RequestContext,
current_turn_id: u32,
tool_results: &mut Vec<ToolResultItem>,
known_hint_paths: &mut Vec<PathBuf>,
pending_hints: &mut Vec<String>,
) {
output.content = truncate_tool_output(&sanitize_transcript(&output.content));
if let Some(image) = image {
emit_image(
&ctx.cmd_tx,
image,
Some(tool_call.id.clone()),
session,
ctx.session_id,
current_turn_id,
);
}
finish_tool_call(request_id, session, tool_call, output, ctx, current_turn_id);
collect_tool_result(CollectToolResultParams {
tool_results,
session,
tool_call,
output,
known_hint_paths,
pending_hints,
});
}
pub(crate) fn tool_result_from_channel(
tool_name: &str,
exec_start: std::time::Instant,
msg: Result<Result<ToolOutput, ToolError>, crossbeam_channel::RecvError>,
invocation_description: &str,
cancelled: bool,
) -> (ToolOutput, bool) {
match msg {
Ok(Ok(output)) => {
crate::metrics::record_tool_execution(
tool_name,
exec_start.elapsed().as_secs_f64(),
output.is_error,
);
(output, cancelled)
}
Ok(Err(e)) => {
crate::metrics::record_tool_execution(
tool_name,
exec_start.elapsed().as_secs_f64(),
true,
);
(
ToolOutput {
content: e.to_string(),
is_error: true,
invocation_description: invocation_description.to_string(),
..Default::default()
},
cancelled,
)
}
Err(_) => {
crate::metrics::record_tool_execution(
tool_name,
exec_start.elapsed().as_secs_f64(),
true,
);
(
ToolOutput {
content: "tool execution thread panicked".to_string(),
is_error: true,
invocation_description: invocation_description.to_string(),
..Default::default()
},
cancelled,
)
}
}
}
pub(crate) fn drain_queued_or_synthesize(
tool_name: &str,
exec_start: std::time::Instant,
invocation_description: &str,
exec_rx: &crossbeam_channel::Receiver<Result<ToolOutput, ToolError>>,
stop_message: String,
sticky_cancelled: bool,
) -> (ToolOutput, bool) {
match exec_rx.try_recv() {
Ok(msg) => tool_result_from_channel(
tool_name,
exec_start,
Ok(msg),
invocation_description,
sticky_cancelled,
),
Err(crossbeam_channel::TryRecvError::Empty) => {
crate::metrics::record_tool_execution(
tool_name,
exec_start.elapsed().as_secs_f64(),
true,
);
(
ToolOutput {
content: stop_message,
is_error: true,
invocation_description: invocation_description.to_string(),
..Default::default()
},
sticky_cancelled,
)
}
Err(crossbeam_channel::TryRecvError::Disconnected) => {
crate::metrics::record_tool_execution(
tool_name,
exec_start.elapsed().as_secs_f64(),
true,
);
(
ToolOutput {
content: "tool execution thread panicked".to_string(),
is_error: true,
invocation_description: invocation_description.to_string(),
..Default::default()
},
sticky_cancelled,
)
}
}
}
#[expect(clippy::too_many_arguments)]
pub(crate) fn execute_tool_with_timeout(
tool_call: &ChatToolCall,
x_credentials: Option<&ServiceCredential>,
working_dir: Option<&Path>,
timeout_dur: Duration,
request_id: u32,
session_id: u64,
session: &mut SessionState,
cancel_rx: &crossbeam_channel::Receiver<()>,
ctx: &RequestContext,
invocation_description: &str,
) -> (ToolOutput, bool, Option<PreparedImage>) {
let format = match &tool_call.caller {
Some(caller) if caller.kind == "program" => ToolOutputFormat::Json,
_ => ToolOutputFormat::Text,
};
let exec_start = std::time::Instant::now();
let cancel_flag = Arc::new(AtomicBool::new(false));
let tool_ctx = crate::tools::context::ToolContext {
session_id: ctx.session_id,
db: Arc::clone(&ctx.db),
daemon_tx: ctx.daemon_tx.clone(),
active_tool_groups: session.config.active_tool_groups.clone(),
reasoning_effort: session.config.reasoning_effort.clone(),
selected_model: session.config.selected_model.clone(),
working_dir: working_dir.map(|p| p.to_path_buf()),
cancelled: Arc::clone(&cancel_flag),
account_name: session.config.account_name.clone(),
discovered_skills: session.discovered_skills.clone().map(std::sync::Arc::new),
};
let SpawnedToolExecution {
exec_rx: result_rx,
kill_tx,
image_rx,
_forwarder,
} = spawn_tool_execution(
tool_call,
format,
Arc::clone(&ctx.tool_registry),
x_credentials.cloned(),
working_dir.map(|p| p.to_path_buf()),
tool_ctx,
ctx.cmd_tx.clone(),
session_id,
request_id,
);
struct KillGuard(crossbeam_channel::Sender<()>);
impl Drop for KillGuard {
fn drop(&mut self) {
let _ = self.0.send(());
}
}
let _kill_guard = KillGuard(kill_tx);
let deadline = std::time::Instant::now() + timeout_dur;
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
let (output, cancelled) = crossbeam_channel::select_biased! {
recv(cancel_rx) -> _ => {
cancel_flag.store(true, Ordering::Relaxed);
drain_queued_or_synthesize(
&tool_call.name,
exec_start,
invocation_description,
&result_rx,
format!("tool '{}' cancelled", tool_call.name),
true,
)
}
recv(result_rx) -> msg => {
tool_result_from_channel(&tool_call.name, exec_start, msg, invocation_description, false)
}
recv(crossbeam_channel::after(remaining)) -> _ => {
cancel_flag.store(true, Ordering::Relaxed);
drain_queued_or_synthesize(
&tool_call.name,
exec_start,
invocation_description,
&result_rx,
format!(
"tool '{}' timed out after {}s",
tool_call.name,
timeout_dur.as_secs()
),
false,
)
}
};
let image = image_rx.try_recv().ok();
(output, cancelled, image)
}