use std::collections::HashSet;
use std::sync::Arc;
use futures_util::StreamExt;
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use crate::app::events::{AppEvent, ToolDecision};
use crate::entities::chat::DeletedCause;
use crate::entities::message::{
Message, MessageFinish, MessageMetadata, MessageRole, ToolCallRecord,
};
use crate::entities::profile::ToolId;
use crate::entities::sampling::SamplingConfig;
use crate::entities::subagent::{RunKind, RunOutcome, SubagentRun};
use crate::features::tools::subagent::{
CALL_SUBAGENT_ID, START_SUBAGENT_ID, SubagentArgs, withheld_from_subagent,
};
use crate::features::tools::{
ChatEffect, ToolContext, ToolParams, ToolRegistry, TurnInfo, control, effective_tool_ids,
};
use crate::shared::api::{
ApiMessage, ApiToolCall, ChatChunk, ChatRequest, EngineBackend, FinishReason,
ThinkingAccumulator, ThinkingBlock, ThinkingRef, ToolCallAccumulator, VisionSupport,
};
use crate::shared::config::ServerMode;
use crate::shared::session_budget::SessionBudget;
use crate::shared::tokens::estimate_prompt;
use super::Orchestrator;
use super::request::{
PromptContext, RequestEnv, build_request, build_request_in, last_user_message_at,
withhold_images,
};
const VISION_PROBE: std::time::Duration = std::time::Duration::from_secs(5);
pub(super) enum GenMessage {
Progress { id: Uuid, progress: TurnProgress },
Done(GenResult),
}
pub(super) enum TurnProgress {
RoundFiled(Vec<Message>),
ChildStarted(Box<SubagentRun>),
ChildRoundFiled { run: Uuid, messages: Vec<Message> },
OwnStep(StreamStep),
ChildStep { run: Uuid, step: StreamStep },
ChildTokens {
run: Uuid,
completion: u64,
reasoning: Option<u32>,
},
ChildProgress {
run: Uuid,
progress: crate::app::events::SubagentProgress,
},
ChildLineStarted { run: Uuid, role: MessageRole },
ChildTranscript { run: Uuid, messages: Vec<Message> },
ChildEnded {
run: Uuid,
outcome: RunOutcome,
finished_at: chrono::DateTime<chrono::Utc>,
tokens: u64,
},
BackgroundStart(Box<BackgroundStart>),
}
pub(super) enum StreamStep {
Chunk(String),
Thoughts(String),
ToolStarted {
call_id: String,
name: String,
arguments: String,
},
ToolCall {
call_id: String,
name: String,
arguments: String,
result: String,
images: usize,
},
Continue,
Rewrite,
}
pub(super) struct GenResult {
pub(super) id: Uuid,
pub(super) chat_id: Uuid,
pub(super) messages: Vec<Message>,
pub(super) effects: Vec<ChatEffect>,
pub(super) deleted: Vec<Message>,
pub(super) usage: Option<TurnUsage>,
pub(super) continuation: Option<Uuid>,
pub(super) images_withheld: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct TurnUsage {
pub(super) prompt_tokens: u32,
pub(super) completion_tokens: u64,
pub(super) prefill: Option<crate::shared::api::contract::Prefill>,
}
impl TurnUsage {
pub(super) fn next_prompt_estimate(self) -> u64 {
self.prompt_tokens as u64 + self.completion_tokens
}
}
#[derive(Debug, Clone)]
pub(super) struct ContinuationSeed {
pub(super) message_id: Uuid,
pub(super) text: std::sync::Arc<str>,
}
pub(super) struct EchoFilter {
seed: std::sync::Arc<str>,
matched: usize,
decided: bool,
}
impl EchoFilter {
pub(super) fn new(seed: std::sync::Arc<str>) -> Self {
Self {
seed,
matched: 0,
decided: false,
}
}
pub(super) fn push(&mut self, delta: &str) -> String {
if self.decided {
return delta.to_string();
}
let remaining = &self.seed.as_bytes()[self.matched..];
let n = delta.len().min(remaining.len());
if delta.as_bytes()[..n] == remaining[..n] {
self.matched += n;
if self.matched == self.seed.len() {
self.decided = true;
return delta[n..].to_string();
}
String::new()
} else {
self.decided = true;
format!("{}{delta}", &self.seed[..self.matched])
}
}
}
impl Orchestrator {
pub(super) fn handle_send(&mut self, text: String) {
if !self.gen_state.is_idle() {
return;
}
let text = text.trim().to_string();
let has_staged_images = self
.active_id
.and_then(|id| self.staged_images.get(&id))
.is_some_and(|staged| !staged.is_empty());
if text.is_empty() && !has_staged_images {
return;
}
let Some(active_id) = self.active_id else {
let _ = self.evt_tx.send(AppEvent::Error(
self.ui_locale().t("ui.err.no_active_chat").into(),
));
return;
};
if self.parent_of(active_id).is_some() {
let _ = self.evt_tx.send(AppEvent::Error(
self.ui_locale().t("ui.err.read_only_chat").into(),
));
let _ = self.evt_tx.send(AppEvent::RestoreInput(text));
return;
}
let Some(backend) = self.ready_backend() else {
let _ = self.evt_tx.send(AppEvent::RestoreInput(text));
return;
};
let images = self.take_staged_images(active_id);
{
let Some(chat) = self.chat_mut(active_id) else {
return;
};
chat.push_message(Message::user(&text).with_images(images));
chat.draft.clear();
}
self.mark_dirty(active_id);
let _ = self.evt_tx.send(AppEvent::UserMessage(text));
self.start_generation(active_id, backend, None);
if self
.chats
.iter()
.find(|c| c.id == active_id)
.is_some_and(|c| crate::features::rename_chat::is_first_user_message(&c.messages))
{
self.maybe_auto_title(
active_id,
crate::shared::config::AutoTitleMode::AfterUserMessage,
);
}
}
pub(super) fn handle_regenerate(&mut self) {
if !self.gen_state.is_idle() {
return;
}
self.stop_tts();
let Some(active_id) = self.active_id else {
return;
};
let Some(backend) = self.ready_backend() else {
return;
};
{
let Some(chat) = self.chat_mut(active_id) else {
return;
};
let Some(idx) = chat
.messages
.iter()
.rposition(|m| m.role == MessageRole::User || m.is_notification())
else {
return; };
let draft = chat.draft.clone();
let removed = chat.messages.split_off(idx + 1);
chat.record_deleted(removed, draft, DeletedCause::Regenerate);
chat.modified_at = chrono::Utc::now();
}
self.mark_dirty(active_id);
self.activate(active_id); self.emit_chat_list();
self.cancel_orphaned_background_runs(active_id);
self.start_generation(active_id, backend, None);
}
pub(super) fn continuation_supported(&self) -> bool {
self.config.engine.mode.supports_continuation(
self.effective_model_name().as_deref(),
self.endpoint_catalogued(),
)
}
pub(super) fn handle_continue(&mut self) {
if !self.gen_state.is_idle() {
return;
}
let Some(active_id) = self.active_id else {
return;
};
if self.parent_of(active_id).is_some() {
let _ = self.evt_tx.send(AppEvent::Error(
self.ui_locale().t("ui.err.read_only_chat").into(),
));
return;
}
if !self.continuation_supported() {
let key =
if self.config.engine.mode == ServerMode::External && self.endpoint_catalogued() {
"ui.cmd.continue_unsupported_gateway"
} else {
"ui.cmd.continue_unsupported"
};
let _ = self
.evt_tx
.send(AppEvent::Error(self.ui_locale().t(key).into()));
return;
}
let seed = {
let Some(chat) = self.chats.iter().find(|c| c.id == active_id) else {
return;
};
match chat.messages.last() {
Some(m) if m.role == MessageRole::Tool => None,
Some(m) if m.role == MessageRole::Assistant => {
if m.text.is_empty() {
let _ = self.evt_tx.send(AppEvent::Error(
self.ui_locale().t("ui.cmd.continue_thoughts").into(),
));
return;
}
let finish = m.metadata.as_ref().and_then(|md| md.finish);
if finish == Some(crate::entities::message::MessageFinish::Stop) {
let _ = self.evt_tx.send(AppEvent::Error(
self.ui_locale().t("ui.cmd.continue_complete").into(),
));
return;
}
Some(ContinuationSeed {
message_id: m.id,
text: std::sync::Arc::from(m.text.as_str()),
})
}
_ => {
let _ = self.evt_tx.send(AppEvent::Error(
self.ui_locale().t("ui.cmd.continue_nothing").into(),
));
return;
}
}
};
let Some(backend) = self.ready_backend() else {
return;
};
self.start_generation(active_id, backend, seed);
}
pub(super) fn handle_delete_last(&mut self) {
if !self.gen_state.is_idle() {
return;
}
self.stop_tts();
let Some(active_id) = self.active_id else {
return;
};
let user_text;
{
let Some(chat) = self.chat_mut(active_id) else {
return;
};
let Some(idx) = chat
.messages
.iter()
.rposition(|m| m.role == MessageRole::User || m.is_notification())
else {
return; };
user_text = if chat.messages[idx].is_notification() {
String::new()
} else {
chat.messages[idx].text.clone()
};
let draft = chat.draft.clone();
let removed = chat.messages.split_off(idx);
chat.record_deleted(removed, draft, DeletedCause::DeleteExchange);
chat.modified_at = chrono::Utc::now();
}
self.mark_dirty(active_id);
self.activate(active_id); self.emit_chat_list();
self.cancel_orphaned_background_runs(active_id);
let _ = self.evt_tx.send(AppEvent::RestoreInput(user_text));
}
pub(super) fn ready_backend(&self) -> Option<Arc<dyn EngineBackend>> {
match self.engines.backend_if_ready(self.ui_locale()) {
Ok(backend) => Some(backend),
Err(msg) => {
let _ = self.evt_tx.send(AppEvent::Error(msg));
None
}
}
}
pub(super) fn start_generation(
&mut self,
active_id: Uuid,
backend: Arc<dyn EngineBackend>,
continuation: Option<ContinuationSeed>,
) {
self.start_generation_woken(active_id, backend, continuation, false);
}
pub(super) fn start_woken_generation(
&mut self,
active_id: Uuid,
backend: Arc<dyn EngineBackend>,
) {
self.start_generation_woken(active_id, backend, None, true);
}
fn start_generation_woken(
&mut self,
active_id: Uuid,
backend: Arc<dyn EngineBackend>,
continuation: Option<ContinuationSeed>,
woken: bool,
) {
if self.config.tts.stop_on_generation_start {
self.stop_tts();
}
let sampling = self.effective_sampling(active_id);
let Some(chat_ref) = self.chats.iter().find(|c| c.id == active_id) else {
return;
};
let profile_id = chat_ref.profile_id;
let profile_lang = self
.profiles
.iter()
.find(|p| p.id == profile_id)
.map(|p| p.language)
.unwrap_or_default();
let enabled = self
.profiles
.iter()
.find(|p| p.id == profile_id)
.map(|p| p.enabled_tools.clone())
.unwrap_or_default();
let history_upto = chat_ref
.compaction_view(self.config.compaction.enabled)
.map(|(_, upto)| upto);
let workspace = chat_ref.workspace.clone();
let workspace_journal = workspace.as_ref().map(|_| {
self.storage
.json()
.workspace_dir()
.join(active_id.to_string())
});
let files_dir = self.stored_files_dir(active_id);
let allowed = effective_tool_ids(
&enabled,
&crate::features::tools::ToolGates {
web: self.config.tools.web_enabled,
python: self.config.tools.python_enabled,
fs: self.config.tools.fs_enabled,
mcp: self.config.mcp.enabled,
background: self.config.tools.subagent_background,
history: history_upto.is_some(),
workspace: workspace.is_some(),
workspace_commands: workspace
.as_ref()
.map(crate::features::tools::code::WorkspaceCommands::of)
.unwrap_or_default(),
sampling_provider: self.config.engine.mode.cloud_provider(),
sampling_endpoint: self.endpoint_sampling_fields(),
},
);
let history_tools = allowed.iter().any(|t| {
t == crate::features::tools::history::HISTORY_READ_ID
|| t == crate::features::tools::history::HISTORY_SEARCH_ID
});
let stages_files = allowed
.iter()
.any(|t| t == crate::features::tools::PYTHON_EXEC_ID);
let python_dirs = self.config.tools.python_mode.dirs();
let profile_loc = crate::shared::i18n::locale(profile_lang);
let schemas = self.registry.schemas_for(&allowed, profile_loc);
let attach_cfg = self.config.attachments;
let compact_cfg = self.config.compaction.clone();
let indexed: Vec<Uuid> = if chat_ref.attachments.is_empty() {
Vec::new()
} else {
self.storage
.db()
.attachment_indexed_ids(active_id)
.unwrap_or_default()
};
let chat_tools_on = allowed.iter().any(|t| {
t == crate::features::tools::chats::CHAT_SEARCH_ID
|| t == crate::features::tools::chats::CHAT_READ_ID
});
let other_chats: Vec<crate::features::tools::chats::ChatRef> = if chat_tools_on {
crate::features::tools::chats::snapshot_other_chats(&self.chats, profile_id, active_id)
} else {
Vec::new()
};
let self_model_params =
crate::entities::self_model::SelfModelParams::from_settings(&self.config.self_model);
let inject_enabled = enabled
.iter()
.any(|t| t == crate::features::tools::self_model::GET_SELF_MODEL_ID);
if inject_enabled {
crate::features::tools::notes::migrate_self_narrative(&self.storage, profile_id);
}
let self_model = self.storage.db().self_model_get(profile_id).ok().flatten();
let cancel = CancellationToken::new();
let model_name = self.effective_model_name();
let engine_mode = self.config.engine.mode;
let sessions = self.session_budget();
let mut request;
let ctx;
let last_user;
let image_names;
{
let Some(chat) = self.chat_mut(active_id) else {
return;
};
let images: Vec<crate::entities::message_image::MessageImage> = if stages_files {
chat.messages
.iter()
.flat_map(|m| m.images.iter().cloned())
.collect()
} else {
Vec::new()
};
image_names = ToolImageNames::seeded(
chat.messages
.iter()
.flat_map(|m| m.images.iter())
.map(|i| i.name.as_str()),
);
let inputs = if stages_files {
crate::features::chat_inputs::items(
&chat.attachments,
&chat.files,
&images.iter().collect::<Vec<_>>(),
&files_dir,
)
} else {
Vec::new()
};
request = build_request(
chat,
sampling.clone(),
schemas,
&PromptContext {
attachments: &attach_cfg,
compaction: &compact_cfg,
indexed: &indexed,
files: &inputs,
python_dirs,
history_tools,
offered_tools: &allowed,
loc: profile_loc,
},
);
last_user = chat
.messages
.iter()
.rev()
.find(|m| m.role == MessageRole::User)
.map(|m| m.text.clone())
.unwrap_or_default();
let turn = TurnInfo {
profile_id,
chat_id: active_id,
system_message: chat.system_message.clone(),
effective_sampling: sampling,
last_user_message_at: last_user_message_at(chat),
attachments: std::sync::Arc::from(chat.attachments.clone()),
inputs: std::sync::Arc::from(inputs),
history: history_upto
.filter(|_| history_tools)
.and_then(|upto| {
crate::features::compaction::HistoryView::render(
&chat.messages[..upto],
profile_loc,
)
})
.map(std::sync::Arc::new),
other_chats: std::sync::Arc::from(other_chats),
workspace: workspace.clone(),
workspace_journal,
files_dir: Some(files_dir),
files: std::sync::Arc::from(chat.files.clone()),
images: std::sync::Arc::from(images),
stages_files,
lang: profile_lang,
cancel: cancel.clone(),
model_name: model_name.clone(),
engine_mode,
sessions: Some(sessions.clone()),
silent_lane: false,
};
ctx = ToolContext::new(
self.tool_deps(backend.clone()),
ToolParams::from_config(&self.config),
turn,
);
}
request.continue_final = continuation.is_some();
let id = Uuid::new_v4();
let _ = self.evt_tx.send(AppEvent::GenerationStarted {
generation_id: id,
model: model_name.clone(),
continuation: continuation.is_some(),
});
self.gen_state.begin(id, cancel.clone());
self.inflight = Some(super::InflightTurn {
generation: id,
chat: active_id,
rounds: Vec::new(),
partial: Default::default(),
children: Vec::new(),
continuation: continuation.is_some(),
});
let (confirm_tx, confirm_rx) = tokio::sync::mpsc::unbounded_channel();
self.confirm = Some((id, confirm_tx));
spawn_generation(GenSpawn {
backend,
registry: self.registry.clone(),
ctx,
request,
image_names,
cancel,
confirm_dangerous: self.config.tools.confirm_dangerous,
image_cfg: self.config.images,
confirm_rx,
id,
chat_id: active_id,
max_rounds: self.config.max_tool_rounds,
workspace_max_rounds: self.config.workspace.max_rounds,
subagent: SubagentLimits::from_config(&self.config.tools),
sessions,
concurrent_calls: self.config.engine.active_concurrent_calls(),
compaction_summary: self
.chats
.iter()
.find(|c| c.id == active_id)
.and_then(|c| c.compaction_view(self.config.compaction.enabled))
.map(|(s, _)| s.to_string()),
allowed,
self_model,
self_model_params,
inject_enabled,
maintenance_protocol: self.config.self_model.maintenance_protocol,
last_user,
engine_mode,
continuation_supported: self.continuation_supported(),
endpoint_sampling_fields: self.endpoint_sampling_fields(),
model_name,
ui_loc: self.ui_locale(),
compaction_enabled: self.config.compaction.enabled,
continuation,
woken,
evt_tx: self.evt_tx.clone(),
done_tx: self.done_tx.clone(),
background: self.background_slots.clone(),
});
}
fn is_first_reply(&self, res: &GenResult) -> bool {
let substantive = res
.messages
.iter()
.any(|m| m.role == MessageRole::Assistant && !m.text.trim().is_empty());
substantive
&& self
.chats
.iter()
.find(|c| c.id == res.chat_id)
.is_some_and(|c| {
c.messages.iter().any(|m| m.role == MessageRole::User)
&& !crate::features::rename_chat::has_assistant_reply(&c.messages)
})
}
pub(super) fn note_slow_prefill(
&mut self,
prefill: Option<crate::shared::api::contract::Prefill>,
) {
use crate::shared::api::managed::{LLAMA_DEFAULT_BATCH, launched_batch, prefill_hold};
use crate::shared::config::ServerMode;
let Some(prefill) = prefill else { return };
let managed = &self.config.engine.managed;
let (batch, key) = match self.config.engine.mode {
ServerMode::Managed => (
launched_batch(managed.batch_size, managed.gpu_layers),
"ui.notice.slow_prefill_managed",
),
ServerMode::External => (LLAMA_DEFAULT_BATCH, "ui.notice.slow_prefill_external"),
_ => return,
};
let Some(hold) = prefill_hold(batch, prefill) else {
return;
};
let tps = prefill.tokens_per_second().unwrap_or_default().round() as u32;
tracing::info!(
tokens = prefill.tokens,
ms = prefill.ms,
tps,
batch,
hold,
"slow prefill: a cancelled stream would hold its slot for a batch"
);
if !self.engines.claim_prefill_note() {
return;
}
let loc = self.ui_locale();
let _ = self.evt_tx.send(AppEvent::Notice(loc.tf(
key,
&[
("tps", &tps.to_string()),
("hold", &hold.to_string()),
("batch", &batch.to_string()),
],
)));
}
pub(super) fn handle_done(&mut self, res: GenResult) {
if !self.gen_state.finish(res.id) {
return;
}
self.confirm = None;
self.note_withheld_images(res.chat_id, res.images_withheld);
let mut res = res;
self.carry_inflight_rename(&mut res);
if res.messages.is_empty() && res.effects.is_empty() && res.deleted.is_empty() {
self.land_pending_runs(res.chat_id);
return;
}
let self_model_touched = res.messages.iter().any(|m| {
m.tool_calls
.iter()
.any(|tc| crate::features::tools::self_model::is_self_model_tool(&tc.name))
});
let first_reply = self.is_first_reply(&res);
let turn_llm: Option<(String, crate::shared::config::ServerMode)> = res
.messages
.iter()
.rev()
.filter(|m| m.role == MessageRole::Assistant)
.find_map(|m| {
let md = m.metadata.as_ref()?;
Some((md.model.clone()?, md.mode))
});
let landed_runs: Vec<Uuid> = res
.messages
.iter()
.flat_map(|m| m.tool_calls.iter())
.filter_map(|r| r.subagent.as_deref())
.filter(|run| run.final_reply().is_some())
.map(|run| run.id)
.collect();
let mut landed = Landed::default();
if let Some(chat) = self.chat_mut(res.chat_id) {
if !res.deleted.is_empty() {
let deleted = std::mem::take(&mut res.deleted);
chat.record_deleted(deleted, String::new(), DeletedCause::Rewrite);
}
land_continuation(chat, &mut res);
for msg in res.messages {
chat.push_message(msg);
}
landed = apply_effects(chat, res.effects);
self.mark_dirty(res.chat_id);
self.emit_chat_list();
}
for a in landed.attached {
self.insert_attachment(res.chat_id, a, None);
}
self.list_stored_files(res.chat_id, landed.stored);
self.land_pending_runs(res.chat_id);
if let Some((model, mode)) = turn_llm {
self.record_llm_history(res.chat_id, model, mode);
}
if self_model_touched {
let _ = self.evt_tx.send(AppEvent::SelfModelChanged);
}
if first_reply {
self.maybe_auto_title(
res.chat_id,
crate::shared::config::AutoTitleMode::AfterAssistantReply,
);
}
for run in landed_runs {
self.maybe_auto_title_run(run);
}
self.note_slow_prefill(res.usage.as_ref().and_then(|u| u.prefill));
self.maybe_auto_compact(res.chat_id, res.usage);
self.maybe_auto_reflect(res.chat_id);
self.maybe_auto_consolidate(res.chat_id);
self.maybe_auto_self_consolidate(res.chat_id);
}
fn record_llm_history(
&self,
chat_id: Uuid,
model: String,
mode: crate::shared::config::ServerMode,
) {
let Some(profile_id) = self
.chats
.iter()
.find(|c| c.id == chat_id)
.map(|c| c.profile_id)
else {
return;
};
let rec = crate::entities::profile::LlmChange {
changed_at: chrono::Utc::now(),
model,
mode,
};
if let Err(err) = self.storage.db().llm_history_note(profile_id, &rec) {
tracing::warn!(error = %err, profile = %profile_id,
"failed to record the language-model history");
}
}
fn carry_inflight_rename(&mut self, res: &mut GenResult) {
let Some(inflight) = self.inflight.take().filter(|t| t.generation == res.id) else {
return;
};
for edited in inflight
.children
.iter()
.map(|c| &c.run)
.filter(|r| r.renamed_manually)
{
if let Some(run) = res
.messages
.iter_mut()
.flat_map(|m| m.tool_calls.iter_mut())
.filter_map(|r| r.subagent.as_deref_mut())
.find(|r| r.id == edited.id)
{
run.title = edited.title.clone();
run.renamed_manually = true;
}
}
}
pub(super) fn handle_confirm_tool(
&mut self,
generation_id: Uuid,
call_id: String,
decision: ToolDecision,
) {
let Some((id, tx)) = &self.confirm else {
return;
};
if *id != generation_id {
tracing::debug!(reply_for = %generation_id, in_flight = %id,
"dropped a tool confirmation from a finished turn");
return;
}
let _ = tx.send((call_id, decision));
}
}
fn land_continuation(chat: &mut crate::entities::chat::Chat, res: &mut GenResult) {
let Some(seed_id) = res.continuation else {
return;
};
let Some(pos) = res
.messages
.iter()
.position(|m| m.role == MessageRole::Assistant)
else {
return;
};
let round = res.messages.remove(pos);
match chat.messages.iter_mut().rfind(|m| m.id == seed_id) {
Some(seed) => merge_continuation(seed, round),
None => res.messages.insert(pos, round),
}
}
#[derive(Default)]
struct Landed {
attached: Vec<crate::entities::attachment::Attachment>,
stored: Vec<crate::entities::chat_file::ChatFile>,
}
fn apply_effects(chat: &mut crate::entities::chat::Chat, effects: Vec<ChatEffect>) -> Landed {
let mut landed = Landed::default();
for effect in effects {
match effect {
ChatEffect::SetSystemMessage(s) => chat.system_message = s,
ChatEffect::SetSamplingOverride(s) => chat.sampling_override = Some(*s),
ChatEffect::AddAttachment(a) => landed.attached.push(*a),
ChatEffect::AddChatFile(f) => landed.stored.push(*f),
}
}
landed
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RoundLimit {
Tools,
Workspace,
}
struct GenSpawn {
backend: Arc<dyn EngineBackend>,
registry: Arc<ToolRegistry>,
ctx: ToolContext,
request: ChatRequest,
cancel: CancellationToken,
id: Uuid,
chat_id: Uuid,
max_rounds: u32,
workspace_max_rounds: u32,
subagent: SubagentLimits,
sessions: Arc<SessionBudget>,
concurrent_calls: u32,
compaction_summary: Option<String>,
allowed: Vec<ToolId>,
image_names: ToolImageNames,
self_model: Option<crate::entities::self_model::SelfModel>,
self_model_params: crate::entities::self_model::SelfModelParams,
inject_enabled: bool,
maintenance_protocol: bool,
last_user: String,
engine_mode: ServerMode,
continuation_supported: bool,
endpoint_sampling_fields: Option<std::sync::Arc<[String]>>,
model_name: Option<String>,
ui_loc: &'static crate::shared::i18n::Locale,
confirm_dangerous: bool,
image_cfg: crate::shared::config::ImageSettings,
confirm_rx: UnboundedReceiver<(String, ToolDecision)>,
compaction_enabled: bool,
continuation: Option<ContinuationSeed>,
woken: bool,
evt_tx: UnboundedSender<AppEvent>,
done_tx: UnboundedSender<GenMessage>,
background: Arc<BackgroundSlots>,
}
struct ConfirmState {
rx: UnboundedReceiver<(String, ToolDecision)>,
allowed_for_turn: HashSet<ToolId>,
}
struct ConfirmGate<'a> {
enabled: bool,
registry: &'a ToolRegistry,
evt_tx: &'a UnboundedSender<AppEvent>,
cancel: &'a CancellationToken,
id: Uuid,
loc: &'static crate::shared::i18n::Locale,
ctx: &'a ToolContext,
}
async fn confirm_call(
gate: ConfirmGate<'_>,
call: &ApiToolCall,
confirm: &tokio::sync::Mutex<ConfirmState>,
) -> Option<String> {
let mut state = confirm.lock().await;
let ConfirmState {
rx: confirm_rx,
allowed_for_turn,
} = &mut *state;
let needs_ask = gate.enabled
&& !allowed_for_turn.contains(call.name.as_str())
&& gate
.registry
.get(&call.name)
.is_some_and(|tool| tool.danger());
if !needs_ask {
return None;
}
let inputs = (call.name == crate::features::tools::PYTHON_EXEC_ID).then(|| {
use crate::features::chat_inputs::NamedFiles;
let named = match serde_json::from_str::<serde_json::Value>(&call.arguments)
.as_ref()
.map(crate::features::chat_inputs::named_files)
{
Ok(NamedFiles::Named(named)) => named,
Ok(NamedFiles::Malformed) | Err(_) => Vec::new(),
};
crate::features::chat_inputs::for_confirm(&gate.ctx.inputs, &named, gate.ctx.python_net)
});
let _ = gate.evt_tx.send(AppEvent::ToolConfirmRequest {
generation_id: gate.id,
call_id: call.id.clone(),
name: call.name.clone(),
arguments: call.arguments.clone(),
inputs,
});
let decision = tokio::select! {
_ = gate.cancel.cancelled() => None,
reply = wait_for_decision(confirm_rx, &call.id) => reply,
};
match decision {
Some(ToolDecision::AllowForTurn) => {
allowed_for_turn.insert(call.name.clone());
None
}
Some(ToolDecision::Allow) => None,
Some(ToolDecision::Deny) => Some(gate.loc.tf("loop.tool_denied", &[("name", &call.name)])),
None => Some(gate.loc.t("loop.tool_cancelled").to_string()),
}
}
async fn wait_for_decision(
confirm_rx: &mut UnboundedReceiver<(String, ToolDecision)>,
call_id: &str,
) -> Option<ToolDecision> {
loop {
let (id, decision) = confirm_rx.recv().await?;
if id == call_id {
return Some(decision);
}
tracing::debug!(reply_for = %id, waiting_for = %call_id,
"dropped a tool confirmation meant for another call");
}
}
struct RoundOutput {
text: String,
thoughts: String,
thinking: Vec<ThinkingRef>,
calls: Vec<ApiToolCall>,
reason: FinishReason,
tokens: u64,
prefill: Option<crate::shared::api::contract::Prefill>,
prompt_tokens: Option<u32>,
reasoning_tokens: u32,
}
fn spawn_generation(spawn: GenSpawn) {
let GenSpawn {
backend,
registry,
ctx,
mut request,
cancel,
confirm_dangerous,
image_cfg,
confirm_rx,
id,
chat_id,
max_rounds,
workspace_max_rounds,
subagent,
sessions,
concurrent_calls,
allowed,
image_names,
self_model,
self_model_params,
inject_enabled,
maintenance_protocol,
last_user,
engine_mode,
continuation_supported,
endpoint_sampling_fields,
model_name,
ui_loc,
compaction_enabled,
compaction_summary,
continuation,
woken,
evt_tx,
done_tx,
background,
} = spawn;
tokio::spawn(async move {
{
let recent = injection_recent(
&ctx.storage,
ctx.embedder.as_ref(),
ctx.profile_id,
inject_enabled,
&last_user,
&self_model_params,
)
.await;
request.system = inject_self_model(
request.system.take(),
self_model.as_ref(),
inject_enabled,
maintenance_protocol,
&self_model_params,
chrono::Utc::now(),
&recent,
ctx.loc,
);
}
let _ = evt_tx.send(AppEvent::TokenUsage {
generation_id: id,
completion: 0,
context: Some(estimate_prompt_tokens(&request)),
context_exact: false,
reasoning: None,
});
let shared = TurnShared {
background,
backend,
registry,
confirm_dangerous,
image_cfg,
confirm: tokio::sync::Mutex::new(ConfirmState {
rx: confirm_rx,
allowed_for_turn: HashSet::new(),
}),
counters: TurnCounters::default(),
id,
max_rounds,
workspace_max_rounds,
subagent,
sessions,
concurrent_calls,
engine_mode,
continuation_supported,
endpoint_sampling_fields,
model_name,
ui_loc,
evt_tx: evt_tx.clone(),
done_tx: done_tx.clone(),
compaction_enabled,
compaction_summary,
};
let mut turn = TurnLoop {
shared: &shared,
ctx,
request,
cancel,
vision: None,
images_withheld: 0,
image_names,
allowed,
messages: Vec::new(),
effects: Vec::new(),
deleted: Vec::new(),
round: 0,
workspace_rounds: 0,
total_tokens: 0,
total_reasoning: 0,
last_usage: None,
pending_new_bubble: false,
woken,
depth: 0,
run_id: None,
ended_by_limit: None,
persona: None,
echo_seed: continuation.as_ref().map(|c| c.text.clone()),
};
let reason = turn.run().await;
let tail_continuable = match turn.messages.last() {
Some(m) if m.role == MessageRole::Tool => true,
Some(m) if m.role == MessageRole::Assistant => !m.text.is_empty(),
_ => continuation.is_some(),
};
let continuable = turn.shared.continuation_supported
&& matches!(
reason,
FinishReason::Cancelled | FinishReason::Error | FinishReason::Length
)
&& tail_continuable;
let _ = evt_tx.send(AppEvent::Finished {
generation_id: id,
reason,
continuable,
});
let _ = done_tx.send(GenMessage::Done(GenResult {
id,
chat_id,
messages: turn.messages,
effects: turn.effects,
deleted: turn.deleted,
usage: turn.last_usage,
continuation: continuation.map(|c| c.message_id),
images_withheld: turn.images_withheld,
}));
});
}
struct TurnShared {
backend: Arc<dyn EngineBackend>,
registry: Arc<ToolRegistry>,
confirm_dangerous: bool,
image_cfg: crate::shared::config::ImageSettings,
confirm: tokio::sync::Mutex<ConfirmState>,
counters: TurnCounters,
id: Uuid,
max_rounds: u32,
workspace_max_rounds: u32,
subagent: SubagentLimits,
sessions: Arc<SessionBudget>,
concurrent_calls: u32,
engine_mode: ServerMode,
continuation_supported: bool,
endpoint_sampling_fields: Option<std::sync::Arc<[String]>>,
model_name: Option<String>,
ui_loc: &'static crate::shared::i18n::Locale,
evt_tx: UnboundedSender<AppEvent>,
done_tx: UnboundedSender<GenMessage>,
compaction_enabled: bool,
compaction_summary: Option<String>,
background: Arc<BackgroundSlots>,
}
#[derive(Default)]
struct TurnCounters {
tokens: std::sync::atomic::AtomicU64,
reasoning: std::sync::atomic::AtomicU32,
}
struct TokenReport {
turn_completion: u64,
own_completion: u64,
turn_reasoning: Option<u32>,
own_reasoning: Option<u32>,
context: Option<u64>,
context_exact: bool,
}
struct TurnLoop<'a> {
shared: &'a TurnShared,
ctx: ToolContext,
request: ChatRequest,
cancel: CancellationToken,
vision: Option<VisionSupport>,
images_withheld: usize,
image_names: ToolImageNames,
allowed: Vec<ToolId>,
messages: Vec<Message>,
effects: Vec<ChatEffect>,
deleted: Vec<Message>,
round: u32,
workspace_rounds: u32,
total_tokens: u64,
total_reasoning: u32,
last_usage: Option<TurnUsage>,
pending_new_bubble: bool,
woken: bool,
depth: u8,
run_id: Option<Uuid>,
ended_by_limit: Option<RoundLimit>,
persona: Option<String>,
echo_seed: Option<std::sync::Arc<str>>,
}
#[derive(Debug, Clone, Copy)]
struct SubagentLimits {
max_tokens: usize,
run_timeout: std::time::Duration,
dialogue_run_timeout: std::time::Duration,
parallel: u32,
}
impl SubagentLimits {
fn from_config(tools: &crate::shared::config::ToolSettings) -> Self {
Self {
max_tokens: tools.subagent_max_tokens,
run_timeout: std::time::Duration::from_secs(tools.subagent_run_timeout_secs),
dialogue_run_timeout: std::time::Duration::from_secs(tools.dialogue_run_timeout_secs),
parallel: tools.subagent_parallel,
}
}
}
struct RoundSink<'a> {
evt_tx: &'a UnboundedSender<AppEvent>,
done_tx: &'a UnboundedSender<GenMessage>,
turn: Uuid,
child: Option<Uuid>,
mute_steps: bool,
}
impl RoundSink<'_> {
fn send(&self, event: AppEvent) {
let progress = match self.child {
None => {
let step = stream_step(&event);
let _ = self.evt_tx.send(event);
match step {
Some(step) => TurnProgress::OwnStep(step),
None => return,
}
}
Some(run) => {
if self.mute_steps {
return;
}
match stream_step(&event) {
Some(step) => TurnProgress::ChildStep { run, step },
None => return,
}
}
};
let _ = self.done_tx.send(GenMessage::Progress {
id: self.turn,
progress,
});
}
fn tokens(&self, r: TokenReport) {
match self.child {
None => {
let _ = self.evt_tx.send(AppEvent::TokenUsage {
generation_id: self.turn,
completion: r.turn_completion,
context: r.context,
context_exact: r.context_exact,
reasoning: r.turn_reasoning,
});
}
Some(run) => {
let _ = self.evt_tx.send(AppEvent::TokenUsage {
generation_id: self.turn,
completion: r.turn_completion,
context: None,
context_exact: false,
reasoning: r.turn_reasoning,
});
let _ = self.done_tx.send(GenMessage::Progress {
id: self.turn,
progress: TurnProgress::ChildTokens {
run,
completion: r.own_completion,
reasoning: r.own_reasoning,
},
});
}
}
}
}
fn stream_step(event: &AppEvent) -> Option<StreamStep> {
Some(match event {
AppEvent::Chunk { text, .. } => StreamStep::Chunk(text.clone()),
AppEvent::Thoughts { text, .. } => StreamStep::Thoughts(text.clone()),
AppEvent::ToolCallStarted {
call_id,
name,
arguments,
..
} => StreamStep::ToolStarted {
call_id: call_id.clone(),
name: name.clone(),
arguments: arguments.clone(),
},
AppEvent::ToolCall {
call_id,
name,
arguments,
result,
images,
..
} => StreamStep::ToolCall {
call_id: call_id.clone(),
name: name.clone(),
arguments: arguments.clone(),
result: result.clone(),
images: *images,
},
AppEvent::AssistantContinue { .. } => StreamStep::Continue,
AppEvent::AssistantRewrite { .. } => StreamStep::Rewrite,
_ => return None,
})
}
impl TurnLoop<'_> {
fn shape(&self) -> crate::shared::session_budget::Shape {
if self.depth == 0 {
crate::shared::session_budget::Shape::Turn
} else {
crate::shared::session_budget::Shape::Run
}
}
fn allowed_has(&self, name: &str) -> bool {
self.allowed.iter().any(|t| t == name)
}
fn report_progress(&self, tool: Option<&str>) {
let (Some(name), Some(run)) = (&self.persona, self.run_id) else {
return;
};
let counted = self.round + self.workspace_rounds;
let round = if tool.is_some() { counted } else { counted + 1 };
let progress = crate::app::events::SubagentProgress {
name: name.clone(),
round,
tool: tool.map(str::to_string),
kind: crate::app::events::RunProgressKind::Subagent,
};
self.progress(TurnProgress::ChildProgress {
run,
progress: progress.clone(),
});
let _ = self.shared.evt_tx.send(AppEvent::SubagentProgress {
generation_id: self.shared.id,
run,
progress: Some(progress),
});
}
fn progress(&self, progress: TurnProgress) {
let _ = self.shared.done_tx.send(GenMessage::Progress {
id: self.shared.id,
progress,
});
}
fn report_progress_filed(&self, messages: Vec<Message>) {
self.progress(match self.run_id {
None => TurnProgress::RoundFiled(messages),
Some(run) => TurnProgress::ChildRoundFiled { run, messages },
});
}
fn sink(&self) -> RoundSink<'_> {
RoundSink {
evt_tx: &self.shared.evt_tx,
done_tx: &self.shared.done_tx,
turn: self.shared.id,
child: self.run_id,
mute_steps: false,
}
}
async fn stream(&mut self) -> RoundOutput {
let echo = self.echo_seed.take().map(EchoFilter::new);
let estimate = estimate_prompt_tokens(&self.request);
let floor = self.last_usage.map_or(0, TurnUsage::next_prompt_estimate);
let need = self.shared.sessions.price(
self.shape(),
estimate,
floor,
self.request.sampling.max_tokens.map(|m| m as u64),
);
let out = {
let Some(_session) = self.shared.sessions.acquire(need, &self.cancel).await else {
return cancelled_round();
};
stream_round(
&self.shared.backend,
self.request.clone(),
&self.cancel,
self.shared.id,
&self.sink(),
&self.shared.counters,
self.total_tokens,
self.total_reasoning,
self.shared.ui_loc,
self.shared.compaction_enabled,
self.shared.continuation_supported,
echo,
)
.await
};
self.request.continue_final = false;
if let Some(prompt_tokens) = out.prompt_tokens {
self.shared
.sessions
.record_usage(self.shape(), estimate, prompt_tokens as u64);
let mut prefill = self.last_usage.as_ref().and_then(|u| u.prefill);
crate::shared::api::contract::Prefill::keep_larger(&mut prefill, out.prefill);
self.last_usage = Some(TurnUsage {
prompt_tokens,
completion_tokens: out.tokens,
prefill,
});
}
out
}
async fn run(&mut self) -> FinishReason {
if self.request.messages.iter().any(|m| !m.images.is_empty())
&& self.vision().await == VisionSupport::Unsupported
{
self.images_withheld += withhold_images(&mut self.request.messages, self.ctx.loc);
}
let mut muted_retry_left = self.woken;
loop {
self.report_progress(None);
let mut out = self.stream().await;
self.total_tokens += out.tokens;
self.total_reasoning += out.reasoning_tokens;
if muted_retry_left
&& out.calls.is_empty()
&& out.text.trim().is_empty()
&& out.reason != FinishReason::Cancelled
{
muted_retry_left = false;
self.request.sampling.reasoning_budget = Some(0);
self.report_progress(None);
out = self.stream().await;
self.total_tokens += out.tokens;
self.total_reasoning += out.reasoning_tokens;
}
if out.reason == FinishReason::ToolCalls && !out.calls.is_empty() {
if let Some(reason) = self.tool_round(out).await {
return reason;
}
continue;
}
if let Some(mut m) = finalize_message(
&out,
&self.ctx.effective_sampling,
self.shared.engine_mode,
&self.shared.model_name,
self.shared.endpoint_sampling_fields.as_deref(),
) {
m.new_bubble = self.pending_new_bubble;
self.messages.push(m);
}
return out.reason;
}
}
fn budget_exhausted(&self) -> Option<RoundLimit> {
if self.round >= self.shared.max_rounds {
return Some(RoundLimit::Tools);
}
if self.shared.workspace_max_rounds > 0
&& self.workspace_rounds >= self.shared.workspace_max_rounds
{
return Some(RoundLimit::Workspace);
}
None
}
fn counts_toward_round_limit(&self, name: &str) -> bool {
self.shared
.registry
.get(name)
.is_none_or(|tool| tool.counts_toward_round_limit())
}
async fn final_round(&mut self, limit: RoundLimit) -> FinishReason {
let (key, n) = match limit {
RoundLimit::Tools => ("loop.round_limit_reached", self.shared.max_rounds),
RoundLimit::Workspace => (
"loop.workspace_round_limit_reached",
self.shared.workspace_max_rounds,
),
};
self.sink().send(AppEvent::Error(
self.ctx.loc.tf(key, &[("max_rounds", &n.to_string())]),
));
self.ended_by_limit = Some(limit);
self.request.tools.clear();
let final_out = self.stream().await;
if let Some(mut m) = finalize_message(
&final_out,
&self.ctx.effective_sampling,
self.shared.engine_mode,
&self.shared.model_name,
self.shared.endpoint_sampling_fields.as_deref(),
) {
m.new_bubble = self.pending_new_bubble;
self.messages.push(m);
}
final_out.reason
}
fn file_round(
&mut self,
mut am: Message,
tool_msgs: Vec<Message>,
rewrite: bool,
followup: bool,
) {
if rewrite {
self.deleted.push(am);
self.deleted.extend(tool_msgs);
self.sink().send(AppEvent::AssistantRewrite {
generation_id: self.shared.id,
});
return;
}
am.new_bubble = std::mem::take(&mut self.pending_new_bubble);
let filed: Vec<Message> = std::iter::once(am.clone())
.chain(tool_msgs.iter().cloned())
.collect();
self.report_progress_filed(filed);
self.messages.push(am);
self.messages.extend(tool_msgs);
if followup {
self.pending_new_bubble = true;
self.sink().send(AppEvent::AssistantContinue {
generation_id: self.shared.id,
});
}
}
async fn tool_round(&mut self, out: RoundOutput) -> Option<FinishReason> {
if let Some(limit) = self.budget_exhausted() {
return Some(self.final_round(limit).await);
}
if out
.calls
.iter()
.any(|c| self.counts_toward_round_limit(&c.name))
{
self.round += 1;
} else {
self.workspace_rounds += 1;
}
let rewrite = out
.calls
.iter()
.any(|c| c.name == control::REWRITE_CURRENT_ID && self.allowed_has(&c.name));
let followup = out
.calls
.iter()
.any(|c| c.name == control::SEND_FOLLOWUP_ID && self.allowed_has(&c.name));
let thinking: Vec<ThinkingBlock> = out
.thinking
.iter()
.enumerate()
.map(|(i, r)| ThinkingBlock {
text: if i == 0 {
out.thoughts.clone()
} else {
String::new()
},
signature: r.signature.clone(),
id: r.id.clone(),
})
.collect();
self.request.messages.push(
ApiMessage::assistant_tool_calls(out.text.clone(), out.calls.clone())
.with_thinking_blocks(thinking),
);
let (records, tool_msgs) = self.execute_round(&out.calls, rewrite).await;
let mut am = Message::assistant(out.text.clone());
if !out.thoughts.is_empty() {
am.thoughts = Some(out.thoughts.clone());
}
am.tool_calls = records;
self.file_round(am, tool_msgs, rewrite, followup);
sync_attachments(&mut self.ctx, &self.effects);
sync_files(&mut self.ctx, &self.effects);
if self.ctx.stages_files {
self.ctx.sync_inputs();
}
if self.cancel.is_cancelled() {
return Some(FinishReason::Cancelled);
}
None
}
async fn execute_round(
&mut self,
calls: &[ApiToolCall],
rewrite: bool,
) -> (Vec<ToolCallRecord>, Vec<Message>) {
let (mut results, group, mut announced) = self.resolve_round(calls, rewrite).await;
if !group.is_empty() {
for (i, done) in self.run_group(group).await {
self.effects.extend(done.effects);
self.keep_tool_sample(done.prefill);
self.announce_result(&calls[i], &done.result.text, 0);
announced[i] = true;
results[i] = Some(done.result);
}
}
let mut records: Vec<ToolCallRecord> = Vec::new();
let mut tool_msgs: Vec<Message> = Vec::new();
for (i, call) in calls.iter().enumerate() {
let result = results[i]
.take()
.expect("every call of the round resolves in one of the phases");
self.record_call(
call,
result,
rewrite,
announced[i],
&mut records,
&mut tool_msgs,
)
.await;
}
(records, tool_msgs)
}
async fn resolve_round(
&mut self,
calls: &[ApiToolCall],
rewrite: bool,
) -> (Vec<Option<CallResult>>, Vec<(usize, ChildSpec)>, Vec<bool>) {
let mut results: Vec<Option<CallResult>> = calls.iter().map(|_| None).collect();
let mut group: Vec<(usize, ChildSpec)> = Vec::new();
let mut announced = vec![false; calls.len()];
let mut i = 0;
while i < calls.len() {
let call = &calls[i];
if self.is_group_call(call, rewrite) {
self.queue_group_call(call, i, &mut group, &mut results);
i += 1;
} else if self.is_background_call(call, rewrite) {
results[i] = Some(self.resolve_background_call(call));
i += 1;
} else {
let end = self.segment_end(calls, i, rewrite);
self.resolve_ordinary(&calls[i..end], i, rewrite, &mut results, &mut announced)
.await;
i = end;
}
}
(results, group, announced)
}
fn queue_group_call(
&mut self,
call: &ApiToolCall,
at: usize,
group: &mut Vec<(usize, ChildSpec)>,
results: &mut [Option<CallResult>],
) {
self.announce_call(call);
match self.child_spec(&Self::call_args(call)) {
Ok(spec) => group.push((at, spec)),
Err(refusal) => results[at] = Some(refusal),
}
}
fn resolve_background_call(&mut self, call: &ApiToolCall) -> CallResult {
self.announce_call(call);
self.report_progress(Some(&call.name));
let args = Self::call_args(call);
if call.name == crate::features::tools::dialogue::START_DIALOGUE_ID {
self.start_background_dialogue(&args)
} else {
self.start_background(&args)
}
}
async fn resolve_ordinary(
&mut self,
span: &[ApiToolCall],
at: usize,
rewrite: bool,
results: &mut [Option<CallResult>],
announced: &mut [bool],
) {
if span.len() == 1 {
results[at] = Some(self.resolve_call(&span[0], rewrite).await);
return;
}
for (j, done) in self.run_segment(span).await {
self.effects.extend(done.effects);
self.keep_tool_sample(done.prefill);
results[at + j] = Some(done.result);
announced[at + j] = true;
}
}
fn is_concurrent_call(&self, call: &ApiToolCall, rewrite: bool) -> bool {
!rewrite
&& !control::is_control_tool(&call.name)
&& self.allowed_has(&call.name)
&& self.shared.registry.is_concurrent(&call.name)
}
fn segment_end(&self, calls: &[ApiToolCall], start: usize, rewrite: bool) -> usize {
if self.shared.concurrent_calls <= 1 {
return start + 1;
}
let members = calls[start..]
.iter()
.take_while(|c| self.is_concurrent_call(c, rewrite))
.count();
start + members.max(1)
}
async fn run_segment(&self, calls: &[ApiToolCall]) -> Vec<(usize, CallDone)> {
let names: Vec<&str> = calls.iter().map(|c| c.name.as_str()).collect();
self.report_progress(Some(&names.join(", ")));
for call in calls {
self.announce_call(call);
}
let width = self.shared.concurrent_calls.max(1) as usize;
let members: Vec<_> = calls
.iter()
.enumerate()
.map(|(j, call)| self.invoke_member(j, call))
.collect();
let mut done: Vec<(usize, CallDone)> = futures_util::stream::iter(members)
.buffer_unordered(width)
.collect()
.await;
done.sort_by_key(|(j, _)| *j);
done
}
fn keep_tool_sample(&mut self, sample: Option<crate::shared::api::contract::Prefill>) {
if let Some(usage) = &mut self.last_usage {
crate::shared::api::contract::Prefill::keep_larger(&mut usage.prefill, sample);
}
}
async fn invoke_member(&self, j: usize, call: &ApiToolCall) -> (usize, CallDone) {
let args = Self::call_args(call);
let invoked = tokio::select! {
_ = self.cancel.cancelled() => None,
res = self.shared.registry.invoke(&call.name, &self.ctx, args) => Some(res),
};
let done = match invoked {
None => CallDone {
result: self.ctx.loc.t("loop.tool_cancelled").to_string().into(),
effects: Vec::new(),
prefill: None,
},
Some(Ok(outcome)) => CallDone {
result: CallResult {
text: outcome.result,
images: outcome.images,
subagent: None,
},
effects: outcome.effects,
prefill: outcome.prefill,
},
Some(Err(err)) => CallDone {
result: self
.ctx
.loc
.tf(
"loop.tool_error",
&[("name", &call.name), ("err", &err.to_string())],
)
.into(),
effects: Vec::new(),
prefill: None,
},
};
self.announce_result(call, &done.result.text, done.result.images.len());
(j, done)
}
async fn run_group(&self, group: Vec<(usize, ChildSpec)>) -> Vec<(usize, CallDone)> {
let width = self.shared.subagent.parallel.max(1) as usize;
let shared = self.shared;
let loc = self.ctx.loc;
futures_util::stream::iter(
group
.into_iter()
.map(|(i, spec)| async move { (i, run_child(shared, loc, spec).await) }),
)
.buffer_unordered(width)
.collect()
.await
}
fn call_args(call: &ApiToolCall) -> serde_json::Value {
serde_json::from_str(&call.arguments).unwrap_or_else(|_| serde_json::json!({}))
}
fn announce_call(&self, call: &ApiToolCall) {
self.sink().send(AppEvent::ToolCallStarted {
generation_id: self.shared.id,
call_id: call.id.clone(),
name: call.name.clone(),
arguments: call.arguments.clone(),
});
}
fn announce_result(&self, call: &ApiToolCall, result: &str, images: usize) {
self.sink().send(AppEvent::ToolCall {
generation_id: self.shared.id,
call_id: call.id.clone(),
name: call.name.clone(),
arguments: call.arguments.clone(),
result: result.to_string(),
images,
});
}
async fn resolve_call(&mut self, call: &ApiToolCall, rewrite: bool) -> CallResult {
let args = Self::call_args(call);
let is_control = control::is_control_tool(&call.name);
self.report_progress(Some(&call.name));
if !is_control && !rewrite {
self.announce_call(call);
}
self.resolve_call_result(call, &args, is_control, rewrite)
.await
}
async fn vision(&mut self) -> VisionSupport {
if let Some(known) = self.vision {
return known;
}
let answer = tokio::select! {
biased;
() = self.cancel.cancelled() => VisionSupport::Unknown,
answered = tokio::time::timeout(VISION_PROBE, self.ctx.engine.vision()) => {
answered.unwrap_or(VisionSupport::Unknown)
}
};
self.vision = Some(answer);
answer
}
async fn record_call(
&mut self,
call: &ApiToolCall,
result: CallResult,
rewrite: bool,
announced: bool,
records: &mut Vec<ToolCallRecord>,
tool_msgs: &mut Vec<Message>,
) {
let CallResult {
text: mut result,
images,
subagent,
} = result;
let is_control = control::is_control_tool(&call.name);
let offered = images.len();
let entries: Vec<Option<String>> = images.iter().map(|i| i.entry.clone()).collect();
let (images, fates) = if offered > 0 && self.vision().await == VisionSupport::Unsupported {
(Vec::new(), vec![ImageFate::NoVision; offered])
} else {
let (prepared, taken) = prepare_tool_images(
images,
self.shared.image_cfg,
std::mem::take(&mut self.image_names),
)
.await;
self.image_names = taken;
let fates = prepared
.iter()
.map(|p| match p {
Some(_) => ImageFate::Shown,
None => ImageFate::Dropped,
})
.collect();
(prepared.into_iter().flatten().collect(), fates)
};
say_image_fates(&mut result, self.ctx.loc, &entries, &fates);
if !is_control && !rewrite && !announced {
self.announce_result(call, &result, images.len());
}
self.request.messages.push(
ApiMessage::tool(&call.id, &result).with_images(
images
.iter()
.enumerate()
.map(|(i, image)| {
crate::shared::api::ApiImage::new(
image.mime.clone(),
&image.data,
Some(self.ctx.loc.tf(
"prompt.images.label",
&[("n", &(i + 1).to_string()), ("name", &image.name)],
)),
)
})
.collect(),
),
);
records.push(ToolCallRecord {
id: call.id.clone(),
name: call.name.clone(),
arguments: Self::call_args(call),
result: Some(result.clone()),
thought_signature: call.thought_signature.clone(),
images: images.len(),
subagent,
});
tool_msgs.push(tool_message(call, result).with_images(images));
}
async fn resolve_call_result(
&mut self,
call: &ApiToolCall,
args: &serde_json::Value,
is_control: bool,
rewrite: bool,
) -> CallResult {
if !self.allowed_has(&call.name) {
self.ctx
.loc
.tf("loop.tool_disabled", &[("name", &call.name)])
.into()
} else if is_control {
control::control_permission_text(&call.name, self.ctx.loc).into()
} else if rewrite {
self.ctx.loc.t("loop.rewrite_skipped").to_string().into()
} else if let Some(refusal) = confirm_call(
ConfirmGate {
enabled: self.shared.confirm_dangerous,
registry: &self.shared.registry,
evt_tx: &self.shared.evt_tx,
cancel: &self.cancel,
id: self.shared.id,
loc: self.ctx.loc,
ctx: &self.ctx,
},
call,
&self.shared.confirm,
)
.await
{
refusal.into()
} else if call.name == CALL_SUBAGENT_ID {
self.run_subagent(args).await
} else if call.name == START_SUBAGENT_ID {
self.start_background(args)
} else if call.name == crate::features::tools::dialogue::START_DIALOGUE_ID {
self.start_background_dialogue(args)
} else if call.name == crate::features::tools::dialogue::RUN_DIALOGUE_ID {
self.run_dialogue(args).await
} else {
let invoked = tokio::select! {
_ = self.cancel.cancelled() => None,
res = self.shared.registry.invoke(&call.name, &self.ctx, args.clone()) => Some(res),
};
match invoked {
None => self.ctx.loc.t("loop.tool_cancelled").to_string().into(),
Some(Ok(outcome)) => {
self.effects.extend(outcome.effects);
self.keep_tool_sample(outcome.prefill);
CallResult {
text: outcome.result,
images: outcome.images,
subagent: None,
}
}
Some(Err(err)) => self
.ctx
.loc
.tf(
"loop.tool_error",
&[("name", &call.name), ("err", &err.to_string())],
)
.into(),
}
}
}
}
impl TurnLoop<'_> {
async fn run_subagent(&mut self, args: &serde_json::Value) -> CallResult {
match self.child_spec(args) {
Err(refusal) => refusal,
Ok(spec) => {
let done = run_child(self.shared, self.ctx.loc, spec).await;
self.effects.extend(done.effects);
done.result
}
}
}
fn is_group_call(&self, call: &ApiToolCall, rewrite: bool) -> bool {
call.name == CALL_SUBAGENT_ID && self.depth == 0 && !rewrite && self.allowed_has(&call.name)
}
fn is_background_call(&self, call: &ApiToolCall, rewrite: bool) -> bool {
(call.name == START_SUBAGENT_ID
|| call.name == crate::features::tools::dialogue::START_DIALOGUE_ID)
&& self.depth == 0
&& !rewrite
&& self.allowed_has(&call.name)
}
fn start_background(&mut self, args: &serde_json::Value) -> CallResult {
let loc = self.ctx.loc;
let spec = match self.child_spec_with(args, START_SUBAGENT_ID, CancellationToken::new()) {
Ok(spec) => spec,
Err(refusal) => return refusal,
};
if let Err(out) = self.shared.background.take() {
return loc
.tf(
"tool.start_subagent.result.too_many",
&[("n", &out.to_string())],
)
.into();
}
let placeholder = SubagentRun {
id: spec.run_id,
kind: RunKind::Subagent,
title: spec.parsed.initial_title(),
renamed_manually: false,
name: spec.parsed.name.clone(),
created_at: chrono::Utc::now(),
finished_at: None,
system_message: spec.parsed.system_message.clone(),
sampling_override: None,
messages: vec![spec.user.clone()],
outcome: None,
tokens: 0,
participants: Vec::new(),
background: true,
};
let address = crate::features::chat_links::uri(spec.run_id);
let text = loc.tf(
"tool.start_subagent.result.started",
&[
("name", &spec.parsed.initial_title()),
("address", &address),
],
);
self.progress(TurnProgress::BackgroundStart(Box::new(BackgroundStart {
spec: RunSpec::Subagent(Box::new(spec)),
parts: SharedParts::of(self.shared),
})));
CallResult {
text,
images: Vec::new(),
subagent: Some(Box::new(placeholder)),
}
}
fn start_background_dialogue(&mut self, args: &serde_json::Value) -> CallResult {
use crate::features::tools::dialogue::{self, START_DIALOGUE_ID};
let loc = self.ctx.loc;
let parsed = match dialogue::DialogueArgs::parse(args, loc) {
Ok(parsed) => parsed,
Err(err) => {
return loc
.tf(
"loop.tool_error",
&[("name", START_DIALOGUE_ID), ("err", &err.to_string())],
)
.into();
}
};
if self.depth > 0 {
return loc
.tf("loop.tool_disabled", &[("name", START_DIALOGUE_ID)])
.into();
}
if let Err(out) = self.shared.background.take() {
return loc
.tf(
"tool.start_subagent.result.too_many",
&[("n", &out.to_string())],
)
.into();
}
let spec = DialogueSpec {
run_id: Uuid::new_v4(),
persona: self.ctx.system_message.clone(),
brief: conversation_brief(
self.shared.compaction_summary.as_deref(),
&self.request.messages,
loc,
),
sampling: self.ctx.effective_sampling.clone(),
cancel: CancellationToken::new(),
loc,
args: parsed,
};
let placeholder = spec.placeholder();
let text = loc.tf(
"tool.start_dialogue.result.started",
&[
("name", &placeholder.title),
("address", &crate::features::chat_links::uri(spec.run_id)),
],
);
self.progress(TurnProgress::BackgroundStart(Box::new(BackgroundStart {
spec: RunSpec::Dialogue(Box::new(spec)),
parts: SharedParts::of(self.shared),
})));
CallResult {
text,
images: Vec::new(),
subagent: Some(Box::new(placeholder)),
}
}
fn child_spec(&self, args: &serde_json::Value) -> Result<ChildSpec, CallResult> {
self.child_spec_with(args, CALL_SUBAGENT_ID, self.cancel.child_token())
}
fn child_spec_with(
&self,
args: &serde_json::Value,
tool: &str,
cancel: CancellationToken,
) -> Result<ChildSpec, CallResult> {
let loc = self.ctx.loc;
let parsed = SubagentArgs::parse(args, loc).map_err(|err| {
CallResult::from(loc.tf(
"loop.tool_error",
&[("name", tool), ("err", &err.to_string())],
))
})?;
if self.depth > 0 {
return Err(loc.tf("loop.tool_disabled", &[("name", tool)]).into());
}
let limits = self.shared.subagent;
let unconfirmable = tool == START_SUBAGENT_ID && self.shared.confirm_dangerous;
let allowed: Vec<ToolId> = self
.allowed
.iter()
.filter(|t| !withheld_from_subagent(t))
.filter(|t| {
!(unconfirmable
&& self
.shared
.registry
.get(t)
.is_some_and(|tool| tool.danger()))
})
.cloned()
.collect();
let schemas = self.shared.registry.schemas_for(&allowed, loc);
let mut sampling = self.ctx.effective_sampling.clone();
sampling.max_tokens = Some(
sampling
.max_tokens
.map_or(limits.max_tokens, |m| m.min(limits.max_tokens)),
);
let mut ctx = self.ctx.clone();
ctx.system_message = parsed.system_message.clone();
ctx.effective_sampling = sampling.clone();
ctx.last_user_message_at = Some(chrono::Utc::now());
ctx.history = None;
ctx.cancel = cancel.clone();
let indexed: Vec<Uuid> = if ctx.attachments.is_empty() {
Vec::new()
} else {
ctx.storage
.db()
.attachment_indexed_ids(ctx.chat_id)
.unwrap_or_default()
};
let inputs: &[crate::features::chat_inputs::ChatInput] = if ctx.stages_files
&& allowed
.iter()
.any(|t| t == crate::features::tools::PYTHON_EXEC_ID)
{
&ctx.inputs
} else {
&[]
};
let user = Message::user(parsed.message.clone());
let request = build_request_in(
&parsed.system_message,
std::slice::from_ref(&user),
&RequestEnv {
attachments: &ctx.attachments,
workspace: ctx.workspace.as_ref(),
compaction: None,
},
sampling,
schemas,
&PromptContext {
attachments: &ctx.attachment_cfg,
compaction: &crate::shared::config::CompactionSettings::default(),
indexed: &indexed,
files: inputs,
python_dirs: ctx.python_mode.dirs(),
history_tools: false,
offered_tools: &allowed,
loc,
},
);
Ok(ChildSpec {
parsed,
run_id: Uuid::new_v4(),
allowed,
request,
ctx,
cancel,
user,
limits,
max_rounds: self.shared.max_rounds,
depth: self.depth + 1,
})
}
}
pub(super) struct ChildSpec {
parsed: SubagentArgs,
run_id: Uuid,
allowed: Vec<ToolId>,
request: ChatRequest,
ctx: ToolContext,
cancel: CancellationToken,
user: Message,
limits: SubagentLimits,
max_rounds: u32,
depth: u8,
}
struct CallDone {
result: CallResult,
effects: Vec<ChatEffect>,
prefill: Option<crate::shared::api::contract::Prefill>,
}
pub(super) struct BackgroundStart {
spec: RunSpec,
parts: SharedParts,
}
pub(super) enum RunSpec {
Subagent(Box<ChildSpec>),
Dialogue(Box<DialogueSpec>),
}
pub(super) struct DialogueSpec {
run_id: Uuid,
args: crate::features::tools::dialogue::DialogueArgs,
persona: String,
brief: String,
sampling: SamplingConfig,
cancel: CancellationToken,
loc: &'static crate::shared::i18n::Locale,
}
impl BackgroundStart {
pub(super) fn run_id(&self) -> Uuid {
match &self.spec {
RunSpec::Subagent(spec) => spec.run_id,
RunSpec::Dialogue(spec) => spec.run_id,
}
}
pub(super) fn placeholder(&self) -> SubagentRun {
match &self.spec {
RunSpec::Subagent(spec) => SubagentRun {
id: spec.run_id,
kind: RunKind::Subagent,
title: spec.parsed.initial_title(),
renamed_manually: false,
name: spec.parsed.name.clone(),
created_at: chrono::Utc::now(),
finished_at: None,
system_message: spec.parsed.system_message.clone(),
sampling_override: None,
messages: vec![spec.user.clone()],
outcome: None,
tokens: 0,
participants: Vec::new(),
background: true,
},
RunSpec::Dialogue(spec) => spec.placeholder(),
}
}
}
impl DialogueSpec {
fn placeholder(&self) -> SubagentRun {
SubagentRun {
id: self.run_id,
kind: RunKind::Dialogue,
title: self.args.initial_title(self.loc),
renamed_manually: false,
name: None,
created_at: chrono::Utc::now(),
finished_at: None,
system_message: String::new(),
sampling_override: None,
messages: vec![if self.args.opening_by_a {
Message::assistant(self.args.opening.clone())
} else {
Message::user(self.args.opening.clone())
}],
outcome: None,
tokens: 0,
participants: vec![
crate::entities::subagent::Participant {
name: self.args.a.name.clone(),
system_message: self.args.a.system_message.clone(),
},
crate::entities::subagent::Participant {
name: self.args.b.name.clone(),
system_message: self.args.b.system_message.clone(),
},
],
background: true,
}
}
}
struct SharedParts {
backend: Arc<dyn EngineBackend>,
registry: Arc<ToolRegistry>,
image_cfg: crate::shared::config::ImageSettings,
max_rounds: u32,
workspace_max_rounds: u32,
subagent: SubagentLimits,
concurrent_calls: u32,
engine_mode: ServerMode,
continuation_supported: bool,
endpoint_sampling_fields: Option<std::sync::Arc<[String]>>,
model_name: Option<String>,
ui_loc: &'static crate::shared::i18n::Locale,
compaction_enabled: bool,
compaction_summary: Option<String>,
background: Arc<BackgroundSlots>,
}
impl SharedParts {
fn of(shared: &TurnShared) -> Self {
Self {
backend: shared.backend.clone(),
registry: shared.registry.clone(),
image_cfg: shared.image_cfg,
max_rounds: shared.max_rounds,
workspace_max_rounds: shared.workspace_max_rounds,
subagent: shared.subagent,
concurrent_calls: shared.concurrent_calls,
engine_mode: shared.engine_mode,
continuation_supported: shared.continuation_supported,
endpoint_sampling_fields: shared.endpoint_sampling_fields.clone(),
model_name: shared.model_name.clone(),
ui_loc: shared.ui_loc,
compaction_enabled: shared.compaction_enabled,
compaction_summary: shared.compaction_summary.clone(),
background: shared.background.clone(),
}
}
}
#[derive(Debug)]
pub(super) struct BackgroundSlots {
out: std::sync::atomic::AtomicU32,
max: std::sync::atomic::AtomicU32,
}
impl BackgroundSlots {
pub(super) fn new(max: u32) -> Self {
Self {
out: std::sync::atomic::AtomicU32::new(0),
max: std::sync::atomic::AtomicU32::new(max.max(1)),
}
}
pub(super) fn set_max(&self, max: u32) {
self.max
.store(max.max(1), std::sync::atomic::Ordering::SeqCst);
}
fn take(&self) -> Result<(), u32> {
use std::sync::atomic::Ordering::SeqCst;
let max = self.max.load(SeqCst);
let mut out = self.out.load(SeqCst);
loop {
if out >= max {
return Err(out);
}
match self.out.compare_exchange(out, out + 1, SeqCst, SeqCst) {
Ok(_) => return Ok(()),
Err(seen) => out = seen,
}
}
}
fn release(&self) {
use std::sync::atomic::Ordering::SeqCst;
let _ = self
.out
.fetch_update(SeqCst, SeqCst, |n| Some(n.saturating_sub(1)));
}
}
pub(super) enum BackgroundMessage {
Progress {
generation: Uuid,
progress: TurnProgress,
},
Done {
generation: Uuid,
run: Box<SubagentRun>,
result: String,
effects: Vec<ChatEffect>,
},
}
pub(super) struct BackgroundSpawn {
pub(super) generation: Uuid,
pub(super) sessions: Arc<SessionBudget>,
pub(super) evt_tx: UnboundedSender<AppEvent>,
pub(super) bg_tx: UnboundedSender<BackgroundMessage>,
}
pub(super) fn spawn_background_run(
start: BackgroundStart,
spawn: BackgroundSpawn,
) -> CancellationToken {
let BackgroundStart { mut spec, parts } = start;
let BackgroundSpawn {
generation,
sessions,
evt_tx,
bg_tx,
} = spawn;
let cancel = match &mut spec {
RunSpec::Subagent(spec) => {
spec.ctx.sessions = Some(sessions.clone());
spec.cancel.clone()
}
RunSpec::Dialogue(spec) => spec.cancel.clone(),
};
let (done_tx, mut done_rx) = tokio::sync::mpsc::unbounded_channel::<GenMessage>();
let (end_tx, end_rx) = tokio::sync::oneshot::channel::<BackgroundMessage>();
let forward = bg_tx;
tokio::spawn(async move {
while let Some(message) = done_rx.recv().await {
if let GenMessage::Progress { progress, .. } = message {
let _ = forward.send(BackgroundMessage::Progress {
generation,
progress,
});
}
}
if let Ok(end) = end_rx.await {
let _ = forward.send(end);
}
});
let (_confirm_tx, confirm_rx) = tokio::sync::mpsc::unbounded_channel();
let slots = parts.background.clone();
let shared = TurnShared {
backend: parts.backend,
registry: parts.registry,
confirm_dangerous: false,
image_cfg: parts.image_cfg,
confirm: tokio::sync::Mutex::new(ConfirmState {
rx: confirm_rx,
allowed_for_turn: HashSet::new(),
}),
counters: TurnCounters::default(),
id: generation,
max_rounds: parts.max_rounds,
workspace_max_rounds: parts.workspace_max_rounds,
subagent: parts.subagent,
sessions,
concurrent_calls: parts.concurrent_calls,
engine_mode: parts.engine_mode,
continuation_supported: parts.continuation_supported,
endpoint_sampling_fields: parts.endpoint_sampling_fields.clone(),
model_name: parts.model_name,
ui_loc: parts.ui_loc,
evt_tx,
done_tx,
compaction_enabled: parts.compaction_enabled,
compaction_summary: parts.compaction_summary,
background: parts.background,
};
tokio::spawn(async move {
let (result, effects) = match spec {
RunSpec::Subagent(spec) => {
let loc = spec.ctx.loc;
let CallDone {
result, effects, ..
} = run_child(&shared, loc, *spec).await;
(result, effects)
}
RunSpec::Dialogue(spec) => {
let DialogueSpec {
run_id,
args,
persona,
brief,
sampling,
cancel,
loc,
} = *spec;
let result = DialogueCtx {
shared: &shared,
loc,
sampling,
persona,
brief,
cancel,
depth: 0,
run_id,
}
.run_parsed(args)
.await;
(result, Vec::new())
}
};
slots.release();
let run = result
.subagent
.expect("a background run always lands a run on its result");
let _ = end_tx.send(BackgroundMessage::Done {
generation,
run,
result: result.text,
effects,
});
drop(shared);
});
cancel
}
async fn run_child(
shared: &TurnShared,
loc: &'static crate::shared::i18n::Locale,
spec: ChildSpec,
) -> CallDone {
let ChildSpec {
parsed,
run_id,
allowed,
request,
ctx,
cancel,
user,
limits,
max_rounds,
depth,
} = spec;
let started = chrono::Utc::now();
let progress = |progress: TurnProgress| {
let _ = shared.done_tx.send(GenMessage::Progress {
id: shared.id,
progress,
});
};
progress(TurnProgress::ChildStarted(Box::new(SubagentRun {
id: run_id,
kind: RunKind::Subagent,
title: parsed.initial_title(),
renamed_manually: false,
name: parsed.name.clone(),
created_at: started,
finished_at: None,
system_message: parsed.system_message.clone(),
sampling_override: None,
messages: vec![user.clone()],
outcome: None,
tokens: 0,
participants: Vec::new(),
background: false,
})));
let mut child = TurnLoop {
shared,
ctx,
request,
cancel: cancel.clone(),
vision: None,
images_withheld: 0,
image_names: ToolImageNames::seeded(user.images.iter().map(|i| i.name.as_str())),
allowed,
woken: false,
messages: Vec::new(),
effects: Vec::new(),
deleted: Vec::new(),
round: 0,
workspace_rounds: 0,
total_tokens: 0,
total_reasoning: 0,
last_usage: None,
pending_new_bubble: false,
depth,
run_id: Some(run_id),
ended_by_limit: None,
persona: Some(parsed.initial_title()),
echo_seed: None,
};
let finished = tokio::time::timeout(limits.run_timeout, Box::pin(child.run())).await;
let filtered = matches!(finished, Ok(FinishReason::Filtered));
let outcome = match finished {
Err(_) => {
cancel.cancel();
RunOutcome::TimedOut
}
Ok(FinishReason::Cancelled) => RunOutcome::Cancelled,
Ok(FinishReason::Error) => RunOutcome::Failed,
Ok(_) if child.ended_by_limit.is_some() => RunOutcome::RoundLimit,
Ok(_) => RunOutcome::Completed,
};
let child_messages = std::mem::take(&mut child.messages);
let child_effects = std::mem::take(&mut child.effects);
let child_tokens = child.total_tokens;
drop(child);
let _ = shared.evt_tx.send(AppEvent::SubagentProgress {
generation_id: shared.id,
run: run_id,
progress: None,
});
let finished_at = chrono::Utc::now();
progress(TurnProgress::ChildEnded {
run: run_id,
outcome,
finished_at,
tokens: child_tokens,
});
let mut run = SubagentRun {
id: run_id,
kind: RunKind::Subagent,
title: parsed.initial_title(),
renamed_manually: false,
name: parsed.name.clone(),
created_at: started,
finished_at: Some(finished_at),
system_message: parsed.system_message.clone(),
sampling_override: None,
messages: std::iter::once(user).chain(child_messages).collect(),
outcome: Some(outcome),
tokens: child_tokens,
participants: Vec::new(),
background: false,
};
let mut effects = Vec::new();
for effect in child_effects {
match effect {
ChatEffect::SetSystemMessage(s) => run.system_message = s,
ChatEffect::SetSamplingOverride(s) => run.sampling_override = Some(*s),
a @ (ChatEffect::AddAttachment(_) | ChatEffect::AddChatFile(_)) => effects.push(a),
}
}
let address = crate::features::chat_links::uri(run.id);
let body = run
.final_reply()
.map(str::to_string)
.unwrap_or_else(|| loc.t("tool.call_subagent.result.empty").to_string());
let status = match outcome {
RunOutcome::Completed if filtered => loc.tf(
"tool.call_subagent.result.filtered",
&[("address", &address)],
),
RunOutcome::Completed => loc.tf(
"tool.call_subagent.result.transcript",
&[("address", &address)],
),
RunOutcome::Cancelled => loc.tf(
"tool.call_subagent.result.cancelled",
&[("address", &address)],
),
RunOutcome::TimedOut => loc.tf(
"tool.call_subagent.result.timeout",
&[
("address", &address),
("secs", &limits.run_timeout.as_secs().to_string()),
],
),
RunOutcome::Failed => loc.tf("tool.call_subagent.result.failed", &[("address", &address)]),
RunOutcome::RoundLimit => loc.tf(
"tool.call_subagent.result.round_limit",
&[
("address", &address),
("max_rounds", &max_rounds.to_string()),
],
),
};
CallDone {
result: CallResult {
text: format!("{body}\n\n{status}"),
images: Vec::new(),
subagent: Some(Box::new(run)),
},
effects,
prefill: None,
}
}
enum DialogueEnd {
Stopped {
reason: String,
summary: Option<String>,
},
Cap,
Cancelled,
Failed {
who: String,
},
}
struct DialogueState {
run_id: Uuid,
title: String,
transcript: Vec<Message>,
notes_a: Vec<String>,
notes_b: Vec<String>,
generated: usize,
next_checkpoint: usize,
rendered: usize,
director_msgs: Vec<ApiMessage>,
tokens: u64,
reasoning: u32,
}
fn conversation_brief(
summary: Option<&str>,
messages: &[ApiMessage],
loc: &'static crate::shared::i18n::Locale,
) -> String {
const BRIEF_BUDGET: usize = 4000;
let mut tail: Vec<String> = Vec::new();
let mut spent = 0usize;
for m in messages.iter().rev() {
let label = match m.role {
crate::shared::api::contract::ApiRole::User => loc.t("prompt.dialogue.role_user"),
crate::shared::api::contract::ApiRole::Assistant => {
loc.t("prompt.dialogue.role_assistant")
}
_ => continue,
};
let text = m.content.trim();
if text.is_empty() {
continue;
}
let line = format!("{label}: {text}");
if spent + line.len() > BRIEF_BUDGET && !tail.is_empty() {
break;
}
spent += line.len();
tail.push(line);
if spent > BRIEF_BUDGET {
break;
}
}
tail.reverse();
let mut parts: Vec<String> = Vec::new();
if let Some(s) = summary.map(str::trim).filter(|s| !s.is_empty()) {
parts.push(s.to_string());
}
if !tail.is_empty() {
parts.push(tail.join("\n"));
}
if parts.is_empty() {
return String::new();
}
format!("{}\n{}", loc.t("prompt.dialogue.brief"), parts.join("\n\n"))
}
struct DialogueCtx<'a> {
shared: &'a TurnShared,
loc: &'static crate::shared::i18n::Locale,
sampling: SamplingConfig,
persona: String,
brief: String,
cancel: CancellationToken,
depth: u8,
run_id: Uuid,
}
impl TurnLoop<'_> {
async fn run_dialogue(&mut self, args: &serde_json::Value) -> CallResult {
DialogueCtx {
shared: self.shared,
loc: self.ctx.loc,
sampling: self.ctx.effective_sampling.clone(),
persona: self.ctx.system_message.clone(),
brief: conversation_brief(
self.shared.compaction_summary.as_deref(),
&self.request.messages,
self.ctx.loc,
),
cancel: self.cancel.child_token(),
depth: self.depth,
run_id: Uuid::new_v4(),
}
.run(args)
.await
}
}
impl DialogueCtx<'_> {
fn dialogue_chip(
&self,
run: Uuid,
title: &str,
round: u32,
kind: crate::app::events::RunProgressKind,
) {
let progress = crate::app::events::SubagentProgress {
name: title.to_string(),
round,
tool: None,
kind,
};
self.progress(TurnProgress::ChildProgress {
run,
progress: progress.clone(),
});
let _ = self.shared.evt_tx.send(AppEvent::SubagentProgress {
generation_id: self.shared.id,
run,
progress: Some(progress),
});
}
fn progress(&self, progress: TurnProgress) {
let _ = self.shared.done_tx.send(GenMessage::Progress {
id: self.shared.id,
progress,
});
}
async fn run(&self, args: &serde_json::Value) -> CallResult {
use crate::features::tools::dialogue::{self, RUN_DIALOGUE_ID};
let loc = self.loc;
let parsed = match dialogue::DialogueArgs::parse(args, loc) {
Ok(a) => a,
Err(err) => {
return loc
.tf(
"loop.tool_error",
&[("name", RUN_DIALOGUE_ID), ("err", &err.to_string())],
)
.into();
}
};
if self.depth > 0 {
return loc
.tf("loop.tool_disabled", &[("name", RUN_DIALOGUE_ID)])
.into();
}
self.run_parsed(parsed).await
}
async fn run_parsed(
&self,
parsed: crate::features::tools::dialogue::DialogueArgs,
) -> CallResult {
use crate::features::tools::dialogue;
let loc = self.loc;
let started = chrono::Utc::now();
let limits = self.shared.subagent;
let a_label = parsed.label(true, loc);
let b_label = parsed.label(false, loc);
let mut sampling = self.sampling.clone();
sampling.max_tokens = Some(
sampling
.max_tokens
.map_or(limits.max_tokens, |m| m.min(limits.max_tokens)),
);
let mut muted = sampling.clone();
muted.reasoning_budget = Some(0);
let mut director_sampling = muted.clone();
director_sampling.max_tokens = Some(512.min(limits.max_tokens));
let direction = parsed
.direction
.clone()
.unwrap_or_else(|| loc.t("prompt.dialogue.direction_default").to_string());
let appendix = loc.tf(
"prompt.dialogue.director",
&[("a", &a_label), ("b", &b_label), ("direction", &direction)],
);
let director_system = [self.persona.as_str(), &self.brief, &appendix]
.iter()
.filter(|s| !s.trim().is_empty())
.copied()
.collect::<Vec<_>>()
.join("\n\n");
let verdict_schemas = dialogue::verdict_tools(loc, &a_label, &b_label);
let participants = vec![
crate::entities::subagent::Participant {
name: parsed.a.name.clone(),
system_message: parsed.a.system_message.clone(),
},
crate::entities::subagent::Participant {
name: parsed.b.name.clone(),
system_message: parsed.b.system_message.clone(),
},
];
let opening = if parsed.opening_by_a {
Message::assistant(parsed.opening.clone())
} else {
Message::user(parsed.opening.clone())
};
let run_id = self.run_id;
let cancel = self.cancel.clone();
self.progress(TurnProgress::ChildStarted(Box::new(SubagentRun {
id: run_id,
kind: RunKind::Dialogue,
title: parsed.initial_title(loc),
renamed_manually: false,
name: None,
created_at: started,
finished_at: None,
system_message: appendix.clone(),
sampling_override: None,
messages: vec![opening.clone()],
outcome: None,
tokens: 0,
participants: participants.clone(),
background: false,
})));
let run = run_id;
let mut st = DialogueState {
run_id,
title: parsed.initial_title(loc),
transcript: vec![opening],
notes_a: Vec::new(),
notes_b: Vec::new(),
generated: 0,
next_checkpoint: parsed.moderate_every,
rendered: 0,
director_msgs: Vec::new(),
tokens: 0,
reasoning: 0,
};
let finished = tokio::time::timeout(
limits.dialogue_run_timeout,
Box::pin(self.dialogue_loop(
&mut st,
&parsed,
(&a_label, &b_label),
&director_system,
&director_sampling,
&verdict_schemas,
&sampling,
&muted,
&cancel,
run,
)),
)
.await;
let end = match finished {
Err(_) => {
cancel.cancel();
None
}
Ok(end) => Some(end),
};
let outcome = match &end {
None => RunOutcome::TimedOut,
Some(DialogueEnd::Cancelled) => RunOutcome::Cancelled,
Some(DialogueEnd::Failed { .. }) => RunOutcome::Failed,
Some(DialogueEnd::Cap) => RunOutcome::RoundLimit,
Some(DialogueEnd::Stopped { .. }) => RunOutcome::Completed,
};
let _ = self.shared.evt_tx.send(AppEvent::SubagentProgress {
generation_id: self.shared.id,
run: run_id,
progress: None,
});
let finished_at = chrono::Utc::now();
self.progress(TurnProgress::ChildEnded {
run: run_id,
outcome,
finished_at,
tokens: st.tokens,
});
let run = SubagentRun {
id: run_id,
kind: RunKind::Dialogue,
title: parsed.initial_title(loc),
renamed_manually: false,
name: None,
created_at: started,
finished_at: Some(finished_at),
system_message: appendix,
sampling_override: None,
messages: st.transcript,
outcome: Some(outcome),
tokens: st.tokens,
participants,
background: false,
};
let generated = st.generated.to_string();
let mut status = match &end {
Some(DialogueEnd::Stopped { reason, summary }) => {
let mut s = loc.tf(
"tool.run_dialogue.result.completed",
&[
("a", &a_label),
("b", &b_label),
("messages", &generated),
("reason", reason),
],
);
if let Some(summary) = summary {
s.push('\n');
s.push_str(
&loc.tf("tool.run_dialogue.result.summary", &[("summary", summary)]),
);
}
s
}
Some(DialogueEnd::Cap) => loc.tf(
"tool.run_dialogue.result.cap",
&[
("a", &a_label),
("b", &b_label),
("max_messages", &parsed.max_messages.to_string()),
],
),
Some(DialogueEnd::Cancelled) => loc.t("tool.run_dialogue.result.cancelled").to_string(),
Some(DialogueEnd::Failed { who }) => {
loc.tf("tool.run_dialogue.result.failed", &[("who", who)])
}
None => loc.tf(
"tool.run_dialogue.result.timeout",
&[("secs", &limits.dialogue_run_timeout.as_secs().to_string())],
),
};
status.push_str("\n\n");
status.push_str(&loc.tf(
"tool.run_dialogue.result.transcript",
&[("address", &crate::features::chat_links::uri(run.id))],
));
CallResult {
text: status,
images: Vec::new(),
subagent: Some(Box::new(run)),
}
}
#[allow(clippy::too_many_arguments)] async fn dialogue_loop(
&self,
st: &mut DialogueState,
parsed: &crate::features::tools::dialogue::DialogueArgs,
labels: (&str, &str),
director_system: &str,
director_sampling: &SamplingConfig,
verdict_schemas: &[crate::shared::api::contract::ToolSchema],
sampling: &SamplingConfig,
muted: &SamplingConfig,
cancel: &CancellationToken,
run: Uuid,
) -> DialogueEnd {
use crate::features::tools::dialogue;
loop {
if st.generated >= parsed.max_messages {
return DialogueEnd::Cap;
}
if st.generated >= st.next_checkpoint {
st.next_checkpoint += parsed.moderate_every;
match self
.dialogue_checkpoint(
st,
parsed,
labels,
director_system,
director_sampling,
verdict_schemas,
sampling,
muted,
cancel,
run,
)
.await
{
Ok(Some((reason, summary))) => {
return DialogueEnd::Stopped { reason, summary };
}
Ok(None) => continue,
Err(end) => return end,
}
}
let speaker_a =
dialogue::next_speaker_a(&st.transcript).unwrap_or(!parsed.opening_by_a);
let line = match self
.dialogue_line(
st, parsed, labels, speaker_a, None, sampling, muted, cancel, run,
)
.await
{
Ok(line) => line,
Err(end) => return end,
};
self.progress(TurnProgress::ChildRoundFiled {
run: st.run_id,
messages: vec![line.clone()],
});
st.transcript.push(line);
st.generated += 1;
}
}
#[allow(clippy::too_many_arguments)]
async fn dialogue_line(
&self,
st: &mut DialogueState,
parsed: &crate::features::tools::dialogue::DialogueArgs,
labels: (&str, &str),
speaker_a: bool,
one_shot: Option<&str>,
sampling: &SamplingConfig,
muted: &SamplingConfig,
cancel: &CancellationToken,
run: Uuid,
) -> Result<Message, DialogueEnd> {
use crate::features::tools::dialogue;
let loc = self.loc;
let persona = if speaker_a { &parsed.a } else { &parsed.b };
let notes = if speaker_a { &st.notes_a } else { &st.notes_b };
let who = if speaker_a { labels.0 } else { labels.1 };
let (system, messages) = dialogue::participant_view(
&st.transcript,
speaker_a,
&persona.system_message,
notes,
one_shot,
&dialogue::ViewText {
scene: parsed.scene.as_deref(),
begins: loc.t("prompt.dialogue.begins"),
note_prefix: loc.t("prompt.dialogue.note_prefix"),
},
);
let request = |s: SamplingConfig| ChatRequest {
system: Some(system.clone()),
messages: messages.clone(),
sampling: s,
tools: Vec::new(),
..Default::default()
};
let role = if speaker_a {
MessageRole::Assistant
} else {
MessageRole::User
};
self.progress(TurnProgress::ChildLineStarted {
run: st.run_id,
role,
});
self.dialogue_chip(
st.run_id,
&st.title,
(st.generated + 1) as u32,
crate::app::events::RunProgressKind::DialogueLine,
);
let mut out = self
.dialogue_stream(request(sampling.clone()), cancel, st, run, true)
.await;
match out.reason {
FinishReason::Cancelled => return Err(DialogueEnd::Cancelled),
FinishReason::Error => {
return Err(DialogueEnd::Failed {
who: who.to_string(),
});
}
_ => {}
}
if out.text.trim().is_empty() {
self.progress(TurnProgress::ChildLineStarted {
run: st.run_id,
role,
});
out = self
.dialogue_stream(request(muted.clone()), cancel, st, run, true)
.await;
match out.reason {
FinishReason::Cancelled => return Err(DialogueEnd::Cancelled),
FinishReason::Error => {
return Err(DialogueEnd::Failed {
who: who.to_string(),
});
}
_ => {}
}
if out.text.trim().is_empty() {
return Err(DialogueEnd::Failed {
who: who.to_string(),
});
}
}
let Some(mut m) = finalize_message(
&out,
&self.sampling,
self.shared.engine_mode,
&self.shared.model_name,
self.shared.endpoint_sampling_fields.as_deref(),
) else {
return Err(DialogueEnd::Failed {
who: who.to_string(),
});
};
if !speaker_a {
m.role = MessageRole::User;
}
Ok(m)
}
#[allow(clippy::too_many_arguments)]
async fn dialogue_checkpoint(
&self,
st: &mut DialogueState,
parsed: &crate::features::tools::dialogue::DialogueArgs,
labels: (&str, &str),
director_system: &str,
director_sampling: &SamplingConfig,
verdict_schemas: &[crate::shared::api::contract::ToolSchema],
sampling: &SamplingConfig,
muted: &SamplingConfig,
cancel: &CancellationToken,
run: Uuid,
) -> Result<Option<(String, Option<String>)>, DialogueEnd> {
use crate::features::tools::dialogue::{self, Verdict};
let loc = self.loc;
let user = self.dialogue_script(st, labels);
st.director_msgs.push(ApiMessage::user(user));
let request = ChatRequest {
system: Some(director_system.to_string()),
messages: st.director_msgs.clone(),
sampling: director_sampling.clone(),
tools: verdict_schemas.to_vec(),
..Default::default()
};
self.dialogue_chip(
st.run_id,
&st.title,
st.generated as u32,
crate::app::events::RunProgressKind::DialogueDirector,
);
let out = self.dialogue_stream(request, cancel, st, run, false).await;
match out.reason {
FinishReason::Cancelled => return Err(DialogueEnd::Cancelled),
FinishReason::Error => {
return Err(DialogueEnd::Failed {
who: loc.t("tool.run_dialogue.director_label").to_string(),
});
}
_ => {}
}
st.director_msgs
.push(ApiMessage::assistant(dialogue::verdict_turn(
&out.text, &out.calls,
)));
let (verdicts, _unknown) = dialogue::parse_verdicts(&out.calls);
for verdict in verdicts {
match verdict {
Verdict::Continue => {}
Verdict::Stop { reason, summary } => {
self.dialogue_stop(st, &reason, summary.as_deref());
return Ok(Some((reason, summary)));
}
Verdict::Note { to_a, to_b, text } => {
self.dialogue_note(st, labels, (to_a, to_b), text);
}
Verdict::Retry { note } => {
self.dialogue_retry(
st,
parsed,
labels,
note.as_deref(),
sampling,
muted,
cancel,
run,
)
.await?;
}
Verdict::Rewrite { text } => self.dialogue_rewrite(st, labels, text),
}
}
Ok(None)
}
fn dialogue_script(&self, st: &mut DialogueState, labels: (&str, &str)) -> String {
let loc = self.loc;
let new_lines: Vec<String> = st.transcript[st.rendered..]
.iter()
.filter(|m| m.role != MessageRole::System)
.map(|m| {
let who = if m.role == MessageRole::Assistant {
labels.0
} else {
labels.1
};
format!("{who}: {}", m.text)
})
.collect();
st.rendered = st.transcript.len();
let header = if st.director_msgs.is_empty() {
loc.t("prompt.dialogue.script_opening")
} else {
loc.t("prompt.dialogue.script_more")
};
format!(
"{header}\n\n{}\n\n{}",
new_lines.join("\n\n"),
loc.t("prompt.dialogue.ask")
)
}
fn dialogue_stop(&self, st: &mut DialogueState, reason: &str, summary: Option<&str>) {
let loc = self.loc;
let line = match summary {
Some(s) => loc.tf(
"tool.run_dialogue.stop_line_summary",
&[("reason", reason), ("summary", s)],
),
None => loc.tf("tool.run_dialogue.stop_line", &[("reason", reason)]),
};
self.dialogue_intervention(st, line);
}
fn dialogue_note(
&self,
st: &mut DialogueState,
labels: (&str, &str),
to: (bool, bool),
text: String,
) {
let loc = self.loc;
let (to_a, to_b) = to;
let whom = match to {
(true, false) => labels.0.to_string(),
(false, true) => labels.1.to_string(),
_ => format!("{}, {}", labels.0, labels.1),
};
self.dialogue_intervention(
st,
loc.tf(
"tool.run_dialogue.note_line",
&[("to", &whom), ("text", &text)],
),
);
if to_a {
st.notes_a.push(text.clone());
}
if to_b {
st.notes_b.push(text);
}
}
#[allow(clippy::too_many_arguments)]
async fn dialogue_retry(
&self,
st: &mut DialogueState,
parsed: &crate::features::tools::dialogue::DialogueArgs,
labels: (&str, &str),
note: Option<&str>,
sampling: &SamplingConfig,
muted: &SamplingConfig,
cancel: &CancellationToken,
run: Uuid,
) -> Result<(), DialogueEnd> {
let loc = self.loc;
if st.generated == 0 || st.generated >= parsed.max_messages {
return Ok(());
}
let Some(last) = st
.transcript
.iter()
.rposition(|m| m.role != MessageRole::System)
else {
return Ok(());
};
let speaker_a = st.transcript[last].role == MessageRole::Assistant;
let who = if speaker_a { labels.0 } else { labels.1 };
st.transcript.remove(last);
let line = match note {
Some(n) => loc.tf(
"tool.run_dialogue.retry_line_note",
&[("who", who), ("note", n)],
),
None => loc.tf("tool.run_dialogue.retry_line", &[("who", who)]),
};
st.transcript.push(Message::new(MessageRole::System, line));
st.rendered = st.transcript.len();
self.progress(TurnProgress::ChildTranscript {
run: st.run_id,
messages: st.transcript.clone(),
});
let line = self
.dialogue_line(
st, parsed, labels, speaker_a, note, sampling, muted, cancel, run,
)
.await?;
self.progress(TurnProgress::ChildRoundFiled {
run: st.run_id,
messages: vec![line.clone()],
});
st.transcript.push(line);
st.generated += 1;
Ok(())
}
fn dialogue_rewrite(&self, st: &mut DialogueState, labels: (&str, &str), text: String) {
let loc = self.loc;
let Some(last) = st
.transcript
.iter()
.rposition(|m| m.role != MessageRole::System)
else {
return;
};
let who = if st.transcript[last].role == MessageRole::Assistant {
labels.0
} else {
labels.1
};
let line = loc.tf("tool.run_dialogue.rewrite_line", &[("who", who)]);
st.transcript[last].text = text;
st.transcript[last].thoughts = None;
st.transcript.push(Message::new(MessageRole::System, line));
st.rendered = st.transcript.len();
self.progress(TurnProgress::ChildTranscript {
run: st.run_id,
messages: st.transcript.clone(),
});
}
fn dialogue_intervention(&self, st: &mut DialogueState, text: String) {
let m = Message::new(MessageRole::System, text);
self.progress(TurnProgress::ChildRoundFiled {
run: st.run_id,
messages: vec![m.clone()],
});
st.transcript.push(m);
st.rendered = st.transcript.len();
}
async fn dialogue_stream(
&self,
request: ChatRequest,
cancel: &CancellationToken,
st: &mut DialogueState,
run: Uuid,
steps: bool,
) -> RoundOutput {
let sink = RoundSink {
evt_tx: &self.shared.evt_tx,
done_tx: &self.shared.done_tx,
turn: self.shared.id,
child: Some(run),
mute_steps: !steps,
};
let need = self.shared.sessions.price(
crate::shared::session_budget::Shape::Run,
estimate_prompt_tokens(&request),
0,
request.sampling.max_tokens.map(|m| m as u64),
);
let Some(_session) = self.shared.sessions.acquire(need, cancel).await else {
return cancelled_round();
};
let out = stream_round(
&self.shared.backend,
request,
cancel,
self.shared.id,
&sink,
&self.shared.counters,
st.tokens,
st.reasoning,
self.shared.ui_loc,
self.shared.compaction_enabled,
false,
None,
)
.await;
st.tokens += out.tokens;
st.reasoning += out.reasoning_tokens;
out
}
}
struct CallResult {
text: String,
images: Vec<crate::features::tools::ToolImage>,
subagent: Option<Box<SubagentRun>>,
}
impl From<String> for CallResult {
fn from(text: String) -> Self {
Self {
text,
images: Vec::new(),
subagent: None,
}
}
}
const TOOL_IMAGE_PREFIX: &str = "tool-image-";
#[derive(Debug, Default)]
struct ToolImageNames(std::collections::HashSet<u32>);
impl ToolImageNames {
fn seeded<'a>(names: impl Iterator<Item = &'a str>) -> Self {
Self(names.filter_map(Self::number_of).collect())
}
fn number_of(name: &str) -> Option<u32> {
let stem = name.rsplit_once('.').map_or(name, |(s, _)| s);
stem.strip_prefix(TOOL_IMAGE_PREFIX)?.parse().ok()
}
fn next(&mut self, mime: &str) -> String {
let mut n = 1;
while !self.0.insert(n) {
n += 1;
}
format!("{TOOL_IMAGE_PREFIX}{n}.{}", ext_of(mime))
}
}
async fn prepare_tool_images(
images: Vec<crate::features::tools::ToolImage>,
cfg: crate::shared::config::ImageSettings,
mut taken: ToolImageNames,
) -> (
Vec<Option<crate::entities::message_image::MessageImage>>,
ToolImageNames,
) {
if images.is_empty() {
return (Vec::new(), taken);
}
let offered = images.len();
tokio::task::spawn_blocking(move || {
use base64::Engine as _;
let b64 = base64::engine::general_purpose::STANDARD;
let prepared = images
.into_iter()
.map(|image| {
let raw = b64.decode(&image.data).ok()?;
if raw.len() as u64 > cfg.max_bytes {
tracing::warn!(bytes = raw.len(), "tool image over the size cap, dropped");
return None;
}
let prepared =
crate::features::image_prepare::prepare(&raw, cfg.downscale_px).ok()?;
Some(crate::entities::message_image::MessageImage::new(
taken.next(prepared.mime),
format!("tool:{}", Uuid::new_v4()),
prepared.mime,
prepared.width,
prepared.height,
b64.encode(&prepared.bytes),
))
})
.collect();
(prepared, taken)
})
.await
.unwrap_or_else(|_| {
(
std::iter::repeat_with(|| None).take(offered).collect(),
ToolImageNames::default(),
)
})
}
fn push_note(result: &mut String, note: &str) {
if !result.is_empty() {
result.push_str("\n\n");
}
result.push_str(note);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ImageFate {
Shown,
NoVision,
Dropped,
}
pub(super) fn say_image_fates(
result: &mut String,
loc: &crate::shared::i18n::Locale,
entries: &[Option<String>],
fates: &[ImageFate],
) {
let (mut no_vision, mut dropped) = (0usize, 0usize);
for (entry, fate) in entries.iter().zip(fates) {
let suffix = match fate {
ImageFate::Shown => "loop.image_shown",
ImageFate::NoVision => "loop.image_not_shown_no_vision",
ImageFate::Dropped => "loop.image_not_shown_dropped",
};
if let Some(line) = entry {
if end_line(result, line, loc.t(suffix)) {
continue;
}
tracing::warn!(line = %line, "a tool image's line is not in its result");
}
match fate {
ImageFate::Shown => {}
ImageFate::NoVision => no_vision += 1,
ImageFate::Dropped => dropped += 1,
}
}
for (n, key) in [
(no_vision, "loop.images_no_vision"),
(dropped, "loop.images_dropped"),
] {
if n > 0 {
push_note(result, &loc.tf(key, &[("n", &n.to_string())]));
}
}
}
pub(super) fn end_line(text: &mut String, line: &str, suffix: &str) -> bool {
let mut start = 0;
let mut found = None;
for piece in text.split_inclusive('\n') {
if piece.strip_suffix('\n').unwrap_or(piece) == line {
found = Some(start + line.len());
}
start += piece.len();
}
let Some(end) = found else {
return false;
};
text.insert_str(end, suffix);
true
}
fn ext_of(mime: &str) -> &'static str {
if mime == "image/jpeg" { "jpg" } else { "png" }
}
pub(super) fn sync_attachments(ctx: &mut ToolContext, effects: &[ChatEffect]) {
let added: Vec<&crate::entities::attachment::Attachment> = effects
.iter()
.filter_map(|e| match e {
ChatEffect::AddAttachment(a) => Some(a.as_ref()),
_ => None,
})
.collect();
if added.is_empty() {
return;
}
let mut list: Vec<crate::entities::attachment::Attachment> = ctx.attachments.to_vec();
for a in added {
if list.iter().any(|x| x.id == a.id) {
continue; }
list.retain(|x| x.source != a.source);
list.push(a.clone());
}
ctx.attachments = list.into();
}
pub(super) fn sync_files(ctx: &mut ToolContext, effects: &[ChatEffect]) {
let added: Vec<&crate::entities::chat_file::ChatFile> = effects
.iter()
.filter_map(|e| match e {
ChatEffect::AddChatFile(f) => Some(f.as_ref()),
_ => None,
})
.collect();
if added.is_empty() {
return;
}
let mut list: Vec<crate::entities::chat_file::ChatFile> = ctx.files.to_vec();
for f in added {
if !list
.iter()
.any(|x| crate::entities::chat_file::same_name(&x.name, &f.name))
{
list.push(f.clone());
}
}
ctx.files = list.into();
}
fn engine_error_note(
err: &str,
compaction_enabled: bool,
partial: bool,
continuable: bool,
ui_loc: &'static crate::shared::i18n::Locale,
) -> String {
ui_loc.tf(
engine_error_key(err, compaction_enabled, partial, continuable),
&[("err", err)],
)
}
pub(super) fn engine_error_key(
err: &str,
compaction_enabled: bool,
partial: bool,
continuable: bool,
) -> &'static str {
match (
crate::features::compaction::is_context_overflow(err),
compaction_enabled,
partial,
continuable,
) {
(true, true, _, _) => "ui.err.context_overflow",
(true, false, _, _) => "ui.err.context_overflow_off",
(false, _, true, true) => "ui.err.generation_interrupted_continuable",
(false, _, true, false) => "ui.err.generation_interrupted",
(false, _, false, _) => "ui.err.generation_failed",
}
}
#[allow(clippy::too_many_arguments)]
async fn stream_round(
backend: &Arc<dyn EngineBackend>,
request: ChatRequest,
cancel: &CancellationToken,
id: Uuid,
sink: &RoundSink<'_>,
counters: &TurnCounters,
own_base_tokens: u64,
own_base_reasoning: u32,
ui_loc: &'static crate::shared::i18n::Locale,
compaction_enabled: bool,
continuation_supported: bool,
mut echo: Option<EchoFilter>,
) -> RoundOutput {
let mut text = String::new();
let mut thoughts = String::new();
let mut thinking = ThinkingAccumulator::default();
let mut acc = ToolCallAccumulator::default();
let mut reason = FinishReason::Stop;
let mut streamed: u64 = 0;
let mut usage_tokens: Option<u64> = None;
let mut usage_prompt: Option<u32> = None;
let mut usage_prefill: Option<crate::shared::api::contract::Prefill> = None;
let mut round_reasoning: u32 = 0;
let emit_completion = |streamed: u64| {
use std::sync::atomic::Ordering::Relaxed;
counters.tokens.fetch_add(1, Relaxed);
sink.tokens(TokenReport {
turn_completion: counters.tokens.load(Relaxed),
own_completion: own_base_tokens + streamed,
turn_reasoning: None,
own_reasoning: None,
context: None,
context_exact: false,
});
};
match backend.chat_stream(request, cancel.clone()).await {
Ok(mut stream) => {
while let Some(chunk) = stream.next().await {
match chunk {
ChatChunk::Text(t) => {
let visible = strip_echo(echo.as_mut(), t);
streamed += 1;
emit_completion(streamed);
relay_text(visible, &mut text, sink, id);
}
ChatChunk::Thoughts(t) => {
thoughts.push_str(&t);
streamed += 1;
sink.send(AppEvent::Thoughts {
generation_id: id,
text: t,
});
emit_completion(streamed);
}
ChatChunk::ThoughtsSignature(r) => thinking.push(r),
ChatChunk::ToolCall(delta) => acc.push(delta),
ChatChunk::Usage(u) => {
use std::sync::atomic::Ordering::Relaxed;
let exact = u.completion_tokens as u64;
usage_tokens = Some(exact);
usage_prompt = Some(u.prompt_tokens);
usage_prefill = u.prefill.or(usage_prefill);
round_reasoning = u.reasoning_tokens;
if exact >= streamed {
counters.tokens.fetch_add(exact - streamed, Relaxed);
} else {
counters.tokens.fetch_sub(streamed - exact, Relaxed);
}
counters.reasoning.fetch_add(u.reasoning_tokens, Relaxed);
sink.tokens(TokenReport {
turn_completion: counters.tokens.load(Relaxed),
own_completion: own_base_tokens + exact,
turn_reasoning: Some(counters.reasoning.load(Relaxed)),
own_reasoning: Some(own_base_reasoning + u.reasoning_tokens),
context: Some(u.prompt_tokens as u64),
context_exact: true,
});
}
ChatChunk::Retry {
attempt,
max,
delay,
} => {
sink.send(AppEvent::Retrying {
generation_id: id,
attempt,
max,
delay_secs: delay.as_secs().max(1),
});
}
ChatChunk::Error { message, .. } => {
let partial = !text.is_empty() || !thoughts.is_empty();
let continuable = continuation_supported && !text.is_empty();
sink.send(AppEvent::Error(engine_error_note(
&message,
compaction_enabled,
partial,
continuable,
ui_loc,
)));
reason = FinishReason::Error;
}
ChatChunk::Finished(r) => {
reason = r;
break;
}
}
}
}
Err(err) => {
let err = err.to_string();
sink.send(AppEvent::Error(engine_error_note(
&err,
compaction_enabled,
false,
false,
ui_loc,
)));
reason = FinishReason::Error;
}
}
RoundOutput {
text,
thoughts,
thinking: thinking.finish(),
calls: acc.finish(),
reason,
tokens: usage_tokens.unwrap_or(streamed),
prompt_tokens: usage_prompt,
prefill: usage_prefill,
reasoning_tokens: round_reasoning,
}
}
fn strip_echo(echo: Option<&mut EchoFilter>, t: String) -> String {
match echo {
Some(f) => f.push(&t),
None => t,
}
}
fn relay_text(visible: String, text: &mut String, sink: &RoundSink<'_>, id: Uuid) {
if visible.is_empty() {
return;
}
text.push_str(&visible);
sink.send(AppEvent::Chunk {
generation_id: id,
text: visible,
});
}
pub(super) fn estimate_prompt_tokens(req: &ChatRequest) -> u64 {
let tools = (!req.tools.is_empty()).then(|| crate::shared::api::openai::tools_json(&req.tools));
let mut parts: Vec<&str> = Vec::with_capacity(req.messages.len() + 1);
for m in &req.messages {
parts.push(m.content.as_str());
for tc in &m.tool_calls {
parts.push(tc.arguments.as_str());
}
}
if let Some(t) = &tools {
parts.push(t.as_str());
}
estimate_prompt(req.system.as_deref(), parts)
}
pub(super) fn blend_self_notes(
relevant: Vec<crate::entities::note::Note>,
fresh: &[crate::entities::note::Note],
n: usize,
) -> Vec<crate::entities::note::Note> {
let mut out: Vec<crate::entities::note::Note> = Vec::new();
let mut seen: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
for r in relevant {
if out.len() >= n {
break;
}
if seen.insert(r.id) {
out.push(r);
}
}
if let Some(f) = fresh.first()
&& !seen.contains(&f.id)
{
if out.len() >= n && !out.is_empty() {
out.pop();
}
out.push(f.clone());
}
out
}
pub(super) async fn injection_recent(
storage: &crate::shared::storage::Storage,
embedder: &dyn crate::shared::api::Embedder,
profile_id: Uuid,
inject_enabled: bool,
last_user: &str,
params: &crate::entities::self_model::SelfModelParams,
) -> Vec<crate::entities::self_model::NarrativeSegment> {
if !inject_enabled {
return Vec::new();
}
use crate::features::tools::notes;
let n = params.narrative_in_prompt;
let fresh = notes::self_notes_recent(storage, profile_id, params.max_narrative);
let relevant = notes::self_notes_relevant(storage, embedder, profile_id, last_user, n).await;
let picked = if relevant.is_empty() {
fresh
} else {
blend_self_notes(relevant, &fresh, n)
};
picked
.into_iter()
.map(|nt| crate::entities::self_model::NarrativeSegment {
id: nt.id,
text: nt.content,
created_at: nt.created_at,
})
.collect()
}
#[allow(clippy::too_many_arguments)]
pub(super) fn inject_self_model(
system: Option<String>,
model: Option<&crate::entities::self_model::SelfModel>,
enabled: bool,
maintenance_protocol: bool,
params: &crate::entities::self_model::SelfModelParams,
now: chrono::DateTime<chrono::Utc>,
recent: &[crate::entities::self_model::NarrativeSegment],
loc: &crate::shared::i18n::Locale,
) -> Option<String> {
if !enabled {
return system;
}
let empty;
let m = match model {
Some(m) => m,
None => {
empty = crate::entities::self_model::SelfModel::new(uuid::Uuid::nil());
&empty
}
};
let block = m.render_for_prompt(
params.prompt_cap,
params.narrative_in_prompt,
now,
recent,
loc,
);
let mut parts: Vec<String> = Vec::new();
if let Some(b) = block {
parts.push(b);
}
if maintenance_protocol {
parts.push(crate::features::tools::self_model::maintenance_protocol(
loc,
));
if let Some(hint) = m.summary_fill_hint(params.summary_target_chars, loc) {
parts.push(format!("({hint})"));
}
}
if parts.is_empty() {
return system; }
let inject = parts.join("\n\n");
Some(match system {
Some(s) => format!("{s}\n\n{inject}"),
None => inject,
})
}
fn tool_message(call: &ApiToolCall, result: String) -> Message {
let mut m = Message::new(MessageRole::Tool, result);
m.tool_call_id = Some(call.id.clone());
m.tool_name = Some(call.name.clone());
m
}
fn finalize_message(
out: &RoundOutput,
sampling: &SamplingConfig,
mode: ServerMode,
model: &Option<String>,
endpoint_fields: Option<&[String]>,
) -> Option<Message> {
if out.text.is_empty() && out.thoughts.is_empty() {
return None;
}
let mut m = Message::assistant(out.text.clone());
if !out.thoughts.is_empty() {
m.thoughts = Some(out.thoughts.clone());
}
m.metadata = Some(MessageMetadata {
sampling: sampling.retain_supported(mode.cloud_provider(), endpoint_fields),
mode,
model: model.clone(),
finish: Some(match out.reason {
FinishReason::Length => MessageFinish::Length,
FinishReason::Cancelled => MessageFinish::Cancelled,
FinishReason::Error => MessageFinish::Error,
FinishReason::Stop | FinishReason::ToolCalls | FinishReason::Filtered => {
MessageFinish::Stop
}
}),
});
Some(m)
}
pub(super) fn merge_continuation(seed: &mut Message, round: Message) {
let seed_len = seed.text.len();
seed.text.push_str(&round.text);
if let Some(t) = round.thoughts {
match &mut seed.thoughts {
Some(existing) => {
existing.push_str("\n\n");
existing.push_str(&t);
}
None => seed.thoughts = Some(t),
}
}
seed.tool_calls.extend(round.tool_calls);
let kept_model = seed.metadata.as_ref().and_then(|md| md.model.clone());
let outgrown = round.text.len() > seed_len;
if let Some(mut md) = round.metadata {
if !outgrown && kept_model.is_some() {
md.model = kept_model;
}
seed.metadata = Some(md);
}
}
fn cancelled_round() -> RoundOutput {
RoundOutput {
text: String::new(),
thoughts: String::new(),
thinking: Vec::new(),
calls: Vec::new(),
reason: FinishReason::Cancelled,
tokens: 0,
prompt_tokens: None,
prefill: None,
reasoning_tokens: 0,
}
}
#[cfg(test)]
mod session_tests {
use super::*;
#[test]
fn a_round_cancelled_while_waiting_is_empty() {
let round = cancelled_round();
assert_eq!(round.reason, FinishReason::Cancelled);
assert_eq!((round.tokens, round.prompt_tokens), (0, None));
assert!(round.text.is_empty() && round.calls.is_empty());
}
}