use std::sync::Arc;
use std::time::{Duration, Instant};
use futures_util::StreamExt;
use tokio::sync::mpsc::UnboundedSender;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use super::background::{Acted, BgDone, BgOutcome};
use crate::app::events::BackgroundKind;
use crate::entities::profile::ToolId;
use crate::features::tools::{ToolContext, ToolRegistry};
use crate::shared::api::contract::ChatStream;
use crate::shared::api::{
ApiMessage, ApiToolCall, ChatChunk, ChatRequest, Embedder, EngineBackend, FinishReason,
ToolCallAccumulator,
};
use crate::shared::i18n::Locale;
use crate::shared::session_budget::{Reservation, SILENT_YIELDS_MAX, SessionBudget};
use crate::shared::storage::Storage;
pub(super) struct SummarySemantics {
pub embedder: Arc<dyn Embedder>,
pub storage: Arc<Storage>,
pub profile_id: Uuid,
pub loc: &'static Locale,
}
pub(super) fn due(count: u32, every: usize) -> bool {
every > 0 && (count as usize) >= every
}
pub(super) struct SilentLoop {
pub backend: Arc<dyn EngineBackend>,
pub registry: Arc<ToolRegistry>,
pub ctx: ToolContext,
pub request: ChatRequest,
pub allowed: Vec<ToolId>,
pub cancel: CancellationToken,
pub max_rounds: u32,
pub timeout: Duration,
pub label: &'static str,
pub profile_id: Uuid,
pub kind: BackgroundKind,
pub done_tx: UnboundedSender<BgDone>,
pub acted: Arc<Acted>,
pub summary_semantics: Option<SummarySemantics>,
}
pub(super) fn spawn_silent_loop(spawn: SilentLoop) {
let SilentLoop {
backend,
registry,
ctx,
mut request,
allowed,
cancel,
max_rounds,
timeout,
label,
profile_id,
kind,
done_tx,
acted,
summary_semantics,
} = spawn;
tokio::spawn(async move {
if let Some(ss) = &summary_semantics
&& let Some(section) = crate::features::tools::notes::summary_observation_overlaps(
&ss.storage,
ss.embedder.as_ref(),
ss.profile_id,
ss.loc,
)
.await
&& let Some(first) = request.messages.first_mut()
{
first.content.push_str("\n\n");
first.content.push_str(§ion);
}
let mut prefill = None;
let run = run_rounds(
&backend,
®istry,
&ctx,
&mut request,
&allowed,
&cancel,
max_rounds,
super::background::lane_label(kind),
timeout,
&acted,
&mut prefill,
);
let outcome = match run.await {
Ok(RoundsEnd::Done) => BgOutcome::Done,
Ok(RoundsEnd::Cancelled { wrote }) => {
tracing::info!(%profile_id, wrote, "{label}: stopped");
BgOutcome::Cancelled { consumed: wrote }
}
Ok(RoundsEnd::TimedOut) => {
cancel.cancel();
tracing::warn!(%profile_id, "{label}: time limit exceeded");
BgOutcome::Failed(ctx.loc.t("loop.time_limit_exceeded").to_string())
}
Err(e) => {
tracing::warn!(%profile_id, "{label}: error: {e}");
BgOutcome::Failed(e.to_string())
}
};
let _ = done_tx.send(BgDone {
kind,
outcome,
prefill,
});
});
}
pub(super) enum RoundsEnd {
Done,
Cancelled {
wrote: bool,
},
TimedOut,
}
type RoundOut = (
String,
Vec<ApiToolCall>,
FinishReason,
Option<crate::shared::api::contract::TokenUsage>,
);
enum Streamed {
Round(RoundOut),
Displaced,
Cancelled,
TimedOut,
}
#[allow(clippy::too_many_arguments)]
async fn run_rounds(
backend: &Arc<dyn EngineBackend>,
registry: &Arc<ToolRegistry>,
ctx: &ToolContext,
request: &mut ChatRequest,
allowed: &[ToolId],
cancel: &CancellationToken,
max_rounds: u32,
lane: &'static str,
clock: Duration,
acted: &Acted,
prefill: &mut Option<crate::shared::api::contract::Prefill>,
) -> Result<RoundsEnd, anyhow::Error> {
let mut round: u32 = 0;
let mut last_exact: u64 = 0;
let mut yields: u32 = 0;
let mut left = clock;
let mut wrote = false;
loop {
let estimate = super::generation::estimate_prompt_tokens(request);
let streamed = stream_round(
backend,
ctx,
request,
estimate,
last_exact,
cancel,
lane,
yields < SILENT_YIELDS_MAX,
&mut left,
)
.await?;
let (text, calls, reason, usage) = match streamed {
Streamed::Round(out) => out,
Streamed::Displaced => {
yields += 1;
tracing::info!(
lane,
yields,
"a silent round was displaced by an interactive stream; made again"
);
continue;
}
Streamed::Cancelled => return Ok(RoundsEnd::Cancelled { wrote }),
Streamed::TimedOut => return Ok(RoundsEnd::TimedOut),
};
record_round_usage(ctx, estimate, usage, &mut last_exact, prefill);
if reason == FinishReason::Cancelled {
return Ok(RoundsEnd::Cancelled { wrote });
}
if reason != FinishReason::ToolCalls || calls.is_empty() || round >= max_rounds {
break;
}
round += 1;
acted.enter_tools(wrote);
if cancel.is_cancelled() {
return Ok(RoundsEnd::Cancelled { wrote });
}
request.messages.push(ApiMessage::assistant_tool_calls(
text.clone(),
calls.clone(),
));
let mut report = ToolsReport::default();
let in_time = run_tools(
registry,
ctx,
allowed,
request,
&calls,
&mut left,
&mut report,
)
.await;
crate::shared::api::contract::Prefill::keep_larger(prefill, report.prefill);
if !in_time {
return Ok(RoundsEnd::TimedOut);
}
wrote = acted.leave_tools(wrote, report.wrote);
}
Ok(RoundsEnd::Done)
}
fn record_round_usage(
ctx: &ToolContext,
estimate: u64,
usage: Option<crate::shared::api::contract::TokenUsage>,
last_exact: &mut u64,
prefill: &mut Option<crate::shared::api::contract::Prefill>,
) {
if let Some(u) = usage {
if let Some(budget) = ctx.sessions.as_deref() {
budget.record_usage(
crate::shared::session_budget::Shape::Loop,
estimate,
u.prompt_tokens as u64,
);
}
*last_exact = u.prompt_tokens as u64 + u.completion_tokens as u64;
crate::shared::api::contract::Prefill::keep_larger(prefill, u.prefill);
}
}
#[allow(clippy::too_many_arguments)]
async fn stream_round(
backend: &Arc<dyn EngineBackend>,
ctx: &ToolContext,
request: &ChatRequest,
estimate: u64,
floor: u64,
cancel: &CancellationToken,
lane: &'static str,
yields: bool,
left: &mut Duration,
) -> Result<Streamed, anyhow::Error> {
let Ok(held) = lane_reservation(
ctx.sessions.as_deref(),
request,
estimate,
floor,
cancel,
lane,
yields,
)
.await
else {
return Ok(Streamed::Cancelled);
};
if cancel.is_cancelled() {
return Ok(Streamed::Cancelled);
}
let token = held
.as_ref()
.map_or_else(|| cancel.clone(), Reservation::stream_token);
let started = Instant::now();
let streamed = async {
let stream = backend.chat_stream(request.clone(), token.clone()).await?;
Ok::<RoundOut, anyhow::Error>(read_round(stream).await)
};
let Ok(out) = tokio::time::timeout(*left, streamed).await else {
return Ok(Streamed::TimedOut);
};
let out = out?;
*left = left.saturating_sub(started.elapsed());
if out.2 == FinishReason::Cancelled && held.as_ref().is_some_and(Reservation::displaced) {
return Ok(Streamed::Displaced);
}
Ok(Streamed::Round(out))
}
#[derive(Default)]
struct ToolsReport {
wrote: bool,
prefill: Option<crate::shared::api::contract::Prefill>,
}
async fn run_tools(
registry: &Arc<ToolRegistry>,
ctx: &ToolContext,
allowed: &[ToolId],
request: &mut ChatRequest,
calls: &[ApiToolCall],
left: &mut Duration,
report: &mut ToolsReport,
) -> bool {
let started = Instant::now();
let tools = async {
for call in calls {
let args: serde_json::Value =
serde_json::from_str(&call.arguments).unwrap_or_else(|_| serde_json::json!({}));
let (result, call_wrote, sample) =
invoke_allowed(registry, ctx, allowed, call, args).await;
report.wrote |= call_wrote;
crate::shared::api::contract::Prefill::keep_larger(&mut report.prefill, sample);
request.messages.push(ApiMessage::tool(&call.id, &result));
}
};
if tokio::time::timeout(*left, tools).await.is_err() {
return false;
}
*left = left.saturating_sub(started.elapsed());
true
}
struct Cancelled;
async fn lane_reservation<'a>(
budget: Option<&'a SessionBudget>,
request: &ChatRequest,
estimate: u64,
floor: u64,
cancel: &CancellationToken,
lane: &'static str,
yields: bool,
) -> Result<Option<Reservation<'a>>, Cancelled> {
let Some(budget) = budget else {
return Ok(None);
};
let need = budget.price(
crate::shared::session_budget::Shape::Loop,
estimate,
floor,
request.sampling.max_tokens.map(|m| m as u64),
);
budget
.acquire_silent(need, cancel, lane, yields)
.await
.map(Some)
.ok_or(Cancelled)
}
async fn read_round(mut stream: ChatStream) -> RoundOut {
let mut acc = ToolCallAccumulator::default();
let mut text = String::new();
let mut reason = FinishReason::Stop;
let mut usage = None;
while let Some(chunk) = stream.next().await {
match chunk {
ChatChunk::ToolCall(d) => acc.push(d),
ChatChunk::Text(t) => text.push_str(&t),
ChatChunk::Usage(u) => usage = Some(u),
ChatChunk::Finished(r) => {
reason = r;
break;
}
ChatChunk::Retry {
attempt,
max,
delay,
} => {
tracing::info!(
attempt,
max,
?delay,
"retrying a a background tool-loop turn"
);
}
ChatChunk::Error { message, .. } => {
tracing::warn!(error = %message, "engine error in a background tool loop");
}
ChatChunk::Thoughts(_) | ChatChunk::ThoughtsSignature(_) => {}
}
}
(text, acc.finish(), reason, usage)
}
async fn invoke_allowed(
registry: &Arc<ToolRegistry>,
ctx: &ToolContext,
allowed: &[ToolId],
call: &ApiToolCall,
args: serde_json::Value,
) -> (String, bool, Option<crate::shared::api::contract::Prefill>) {
let allowed_has = |name: &str| allowed.iter().any(|t| t == name);
if allowed_has(&call.name) {
match registry.invoke(&call.name, ctx, args).await {
Ok(o) => (o.result, o.wrote, o.prefill),
Err(e) => (
ctx.loc.tf(
"loop.tool_error",
&[("name", &call.name), ("err", &e.to_string())],
),
true,
None,
),
}
} else {
(
ctx.loc.tf("loop.tool_not_allowed", &[("name", &call.name)]),
false,
None,
)
}
}
#[cfg(test)]
mod tests {
use super::due;
#[test]
fn due_respects_threshold_and_disabled() {
assert!(!due(5, 0)); assert!(!due(1, 3));
assert!(!due(2, 3));
assert!(due(3, 3)); assert!(due(4, 3)); }
}