focus/crates/focus-tui/src/job.rs

255 lines
9.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 后台任务:自动压缩 + 运行 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<Message>,
to_persist: Vec<Message>,
appended_count: usize,
error: Option<String>,
},
}
/// 一次后台任务prompt / continue / 仅压缩)。
/// One background job (prompt / continue / compact-only).
pub struct RunJob {
/// 任务开始时的 transcript。
/// The transcript at job start.
pub transcript: Vec<Message>,
/// 用户消息(`None` 表示仅压缩,不运行 agent
/// The user message (`None` means compact-only, no agent run).
pub user_text: Option<String>,
/// 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<Box<dyn StreamProvider>>,
}
/// 启动一个后台任务。
/// Spawn a background job.
pub fn spawn(job: RunJob, tx: Sender<JobEvent>) -> 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<JobEvent>) {
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<String, String> {
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<dyn StreamProvider> {
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<JobEvent>);
impl EventSink for ChannelSink {
fn emit(&mut self, event: AgentEvent) {
let _ = self.0.send(JobEvent::Agent(event));
}
}