//! 后台任务:自动压缩 + 运行 agent(在独立线程中执行同步的 agent 循环)。 //! Background jobs: auto-compaction + running the agent (the synchronous agent //! loop executes on a dedicated thread). use crate::config::{ProviderKind, TuiConfig}; use focus_core::event::{AgentEvent, EventSink}; use focus_core::model::{ContentBlock, Message}; use focus_core::provider::{ProviderRequest, StreamEvent, StreamProvider}; use focus_core::tool::ToolRegistry; use focus_core::Agent; use focus_harness::compaction::{ apply_summary, plan_compaction, DEFAULT_KEEP_RATIO, DEFAULT_THRESHOLD_RATIO, }; use focus_harness::estimate_messages; use focus_providers::anthropic::AnthropicProvider; use focus_providers::config::{resolve_context_window, ProviderConfig}; use focus_providers::openai::OpenAiProvider; use focus_tools::{EditTool, ReadTool, ShellTool, WriteTool}; use std::path::PathBuf; use std::sync::mpsc::Sender; use std::thread::JoinHandle; use std::time::Duration; /// 压缩触发阈值(估算占用比例)。 /// Compaction trigger threshold (estimated occupancy ratio). pub const COMPACT_THRESHOLD: f64 = DEFAULT_THRESHOLD_RATIO; /// 后台任务发给 UI 的事件。 /// Events the background job sends to the UI. #[derive(Debug)] pub enum JobEvent { /// agent 事件(转发)。 /// An agent event (forwarded). Agent(AgentEvent), /// 状态说明(如"正在压缩…")。 /// A status note (e.g. "compacting…"). Note(String), /// 任务结束。`messages` 为**最终完整 transcript**;`to_persist` 为需要新写入 /// 会话的消息(含压缩产生的摘要消息);`appended_count` 为本轮 agent 实际追加 /// 的消息数(不含摘要消息,用于思考耗时映射)。 /// Job finished. `messages` is the **final full transcript**; /// `to_persist` are the messages that must be newly written to the session /// (including a compaction summary); `appended_count` is how many messages /// the agent run actually appended (excluding the summary; used to map /// thinking durations). Done { messages: Vec, to_persist: Vec, appended_count: usize, error: Option, }, } /// 一次后台任务(prompt / continue / 仅压缩)。 /// One background job (prompt / continue / compact-only). pub struct RunJob { /// 任务开始时的 transcript。 /// The transcript at job start. pub transcript: Vec, /// 用户消息(`None` 表示仅压缩,不运行 agent)。 /// The user message (`None` means compact-only, no agent run). pub user_text: Option, /// TUI 配置(provider / 模型 / 端点)。 /// TUI config (provider / model / endpoints). pub config: TuiConfig, /// 项目根目录(工具的相对路径基准 + shell cwd)。 /// Project root (tools' relative-path base + shell cwd). pub cwd: PathBuf, /// 渲染好的系统提示(含 Usage/上下文占用)。 /// The rendered system prompt (with usage / context occupancy). pub system_prompt: String, /// 注入的 provider(测试用;`None` 时按配置构造)。 /// Injected provider (for tests; `None` builds one from the config). pub provider: Option>, } /// 启动一个后台任务。 /// Spawn a background job. pub fn spawn(job: RunJob, tx: Sender) -> JoinHandle<()> { std::thread::Builder::new() .name("focus-tui-job".into()) .spawn(move || run_job(job, tx)) .expect("spawn tui job thread") } /// 执行任务。 /// Execute the job. fn run_job(job: RunJob, tx: Sender) { let _ = tx.send(JobEvent::Note("starting…".into())); // 0. provider:测试注入或按配置构造;摘要与 agent 共用。 // 0. Provider: injected (tests) or built from config; shared between the // summarizer and the agent. let provider = match job.provider { Some(b) => b, None => build_provider(&job.config), }; // 1. 自动压缩(无用户确认,超过阈值即在回合间执行)。 // 1. Auto-compaction (no user confirmation; runs between turns when over // the threshold). let mut transcript = job.transcript.clone(); let mut compacted = false; let window = resolve_context_window(job.config.context_window, &job.config.model); let estimated = estimate_messages(&transcript); let over = !transcript.is_empty() && (estimated as f64) >= (window as f64) * COMPACT_THRESHOLD; if over { let _ = tx.send(JobEvent::Note(format!( "compacting… ({} tokens used of {})", crate::format::format_tokens(estimated), crate::format::format_tokens(window) ))); if let Some(plan) = plan_compaction(&transcript, window, COMPACT_THRESHOLD, DEFAULT_KEEP_RATIO) { match summarize(provider.as_ref(), &job.config, &plan) { Ok(summary) => { transcript = apply_summary(&summary, &plan); compacted = true; } Err(e) => { let _ = tx.send(JobEvent::Note(format!("compaction failed: {}", e))); } } } } // 2. 构建 agent 并运行(compaction 之后)。 // 2. Build the agent and run (after compaction). let tools = build_tools(&job.cwd); let config = focus_core::AgentConfig { model: job.config.model.clone(), system_prompt: job.system_prompt, max_tokens: Some(4096), ..Default::default() }; let mut agent = Agent::new(config, provider, tools); agent.replace_messages(transcript.clone()); let mut sink = ChannelSink(tx.clone()); let result = match &job.user_text { Some(text) => agent.prompt(text.clone(), &mut sink), None => Ok(()), // 仅压缩任务,不运行 agent / compact-only job }; let final_transcript = agent.messages().to_vec(); // 需要新写入会话的消息:压缩摘要(如有)+ 本轮追加的消息。 // Messages to newly persist: the compaction summary (if any) + the // messages appended by this run. let appended = final_transcript[transcript.len().min(final_transcript.len())..].to_vec(); let mut to_persist = Vec::new(); if compacted { if let Some(first) = final_transcript.first() { to_persist.push(first.clone()); } } to_persist.extend(appended.iter().cloned()); match result { Ok(()) => { let _ = tx.send(JobEvent::Done { messages: final_transcript, to_persist, appended_count: appended.len(), error: None, }); } Err(e) => { let _ = tx.send(JobEvent::Done { messages: final_transcript, to_persist, appended_count: appended.len(), error: Some(e.to_string()), }); } } } /// 用 provider 生成摘要(收集流事件中的 Done 文本)。 /// Generate a summary via the provider (collect the Done text from the stream). fn summarize( provider: &dyn StreamProvider, config: &TuiConfig, plan: &focus_harness::compaction::CompactionPlan, ) -> Result { let request = ProviderRequest { model: config.model.clone(), system_prompt: plan.summary_instruction.clone(), messages: plan.summarize.clone(), tools: focus_json::JsonValue::arr(), max_tokens: Some(2000), temperature: None, }; let mut iter = provider.stream(&request).map_err(|e| e.to_string())?; let mut text = String::new(); while let Some(ev) = iter.next_event() { match ev { StreamEvent::Done { message } => { for block in &message.content { if let ContentBlock::Text(t) = block { text.push_str(&t.text); } } break; } StreamEvent::Error { error } => { return Err(error .error_message .unwrap_or_else(|| "summary stream errored".into())); } _ => {} } } if text.trim().is_empty() { return Err("summary stream produced no text".into()); } Ok(text) } /// 依据配置构造 provider。 /// Build a provider from the config. pub fn build_provider(config: &TuiConfig) -> Box { let pc = ProviderConfig { api_key: config.api_key.clone(), base_url: config.base_url.clone(), context_window: config.context_window, timeout: Duration::from_secs(120), }; match config.provider { ProviderKind::Anthropic => Box::new(AnthropicProvider::new(pc)), ProviderKind::OpenAI => Box::new(OpenAiProvider::new(pc, config.openai_protocol)), } } /// 依据项目根目录构造工具注册表。 /// Build the tool registry for a project root. pub fn build_tools(cwd: &std::path::Path) -> ToolRegistry { let mut registry = ToolRegistry::new(); registry.register(Box::new(ReadTool::new(cwd))); registry.register(Box::new(WriteTool::new(cwd))); registry.register(Box::new(EditTool::new(cwd))); registry.register(Box::new(ShellTool::new(cwd))); registry } /// 把 agent 事件转发到 UI channel 的 sink。 /// A sink that forwards agent events to the UI channel. struct ChannelSink(Sender); impl EventSink for ChannelSink { fn emit(&mut self, event: AgentEvent) { let _ = self.0.send(JobEvent::Agent(event)); } }