diff --git a/plugins/agent/engine/README.md b/plugins/agent/engine/README.md index 0e0e163ced8..2c5572154d6 100644 --- a/plugins/agent/engine/README.md +++ b/plugins/agent/engine/README.md @@ -31,6 +31,6 @@ Verified archive evidence retains its canonical encoding and frozen page. Curren Vision history projection reads each authorized image in full under the existing artifact storage limit. Copied sessions resolve retained images through destination-owned references. -Automatic compaction calibrates the current prompt estimate against the latest matching Main request’s measured input. Only changes since that request are estimated; normalized usage already includes cache and reasoning tokens. A background candidate starts at 85% of the resolved adoption threshold and is adopted only at that threshold or after an actual provider overflow. Ready candidates do not interrupt work. Summary input fitting has its own budget and does not decide when Main must wait. +Automatic compaction calibrates the current prompt estimate against the latest matching Main request’s measured input. Only changes since that request, including archived tool results, are estimated; normalized usage already includes cache and reasoning tokens. A background candidate starts at 85% of the resolved adoption threshold and is adopted during Main only at that threshold or after an actual provider overflow. Ready candidates do not interrupt work. When a turn completes normally, an already completed candidate is saved as a checkpoint for the next turn; unfinished summaries are cancelled without waiting for more output. History after the captured cut remains after the checkpoint. Summary input fitting has its own budget and does not decide when Main must wait. [Source](src/lib.rs) · [Cargo manifest](Cargo.toml) diff --git a/plugins/agent/engine/README.zh-CN.md b/plugins/agent/engine/README.zh-CN.md index 4b9443cb3d7..f989c917d62 100644 --- a/plugins/agent/engine/README.zh-CN.md +++ b/plugins/agent/engine/README.zh-CN.md @@ -31,6 +31,6 @@ 视觉模型的历史投影按既有 artifact 存储上限读取每张已授权图片的完整内容。复制会话通过目标会话持有的引用解析保留的图片。 -自动压缩用最近一次匹配的 Main 请求实测输入校准当前提示词估算,只估算该请求之后的变化;规范化用量已包含缓存和推理 token。在正式采用阈值的 85% 启动后台摘要,到正式阈值或提供商实际返回上下文溢出时才采用,摘要提前完成不会打断工作。摘要输入适配使用自己的预算,不决定 Main 何时等待。 +自动压缩用最近一次匹配的 Main 请求实测输入校准当前提示词估算,只估算该请求之后的变化,包括工具结果归档造成的变化;规范化用量已包含缓存和推理 token。在正式采用阈值的 85% 启动后台摘要,Main 运行期间到正式阈值或提供商实际返回上下文溢出时才采用,摘要提前完成不会打断工作。回合正常结束时,将已经完成的候选摘要保存为下一轮可用的检查点;尚未完成的摘要直接取消,不继续等待输出。捕获切点之后的历史仍保留在检查点之后。摘要输入适配使用自己的预算,不决定 Main 何时等待。 [源码](src/lib.rs) · [Cargo 配置](Cargo.toml) diff --git a/plugins/agent/engine/src/auto_context.rs b/plugins/agent/engine/src/auto_context.rs index 563fcf630aa..cef9a3fcaa0 100644 --- a/plugins/agent/engine/src/auto_context.rs +++ b/plugins/agent/engine/src/auto_context.rs @@ -51,8 +51,7 @@ pub(super) fn calibration( let (Some(binding), LatestMainContext::Selected(latest)) = (binding, latest) else { return Ok(None); }; - if !latest.projection_current - || latest.model_id != provider.model + if latest.model_id != provider.model || latest.route_identity != crate::runner::digest(provider)? || latest.connection_id.as_deref() != Some(binding.connection_id.as_str()) || latest.checkpoint_event_id.as_deref() != checkpoint @@ -66,7 +65,7 @@ pub(super) fn calibration( return Ok(None); }; // The previous input is measured, including cache reads. Only the change in - // rendered input is estimated; prior output and newly settled tools occur once in that change. + // rendered input is estimated, including archived results removed since that request. Ok(Some(Calibration { input, estimated: breakdown.estimated_tokens(), @@ -228,9 +227,7 @@ impl Pending { let Some(checkpoint) = checkpoint else { return Ok(false); }; - let session = checkpoint.invocation.session_id.clone(); - inner.log.append(&EventWrite::plain(checkpoint)?).await?; - inner.shared.clear_loaded(&session)?; + record(inner, checkpoint).await?; Ok(true) } } @@ -300,16 +297,29 @@ pub(super) async fn compact_now( .await } -pub(super) async fn drain(pending: &mut Option) -> Result<(), RunError> { +pub(super) async fn record( + inner: &RunContext, + checkpoint: maka_agent_facts::event::RuntimeEvent, +) -> Result<(), RunError> { + let session = checkpoint.invocation.session_id.clone(); + inner.log.append(&EventWrite::plain(checkpoint)?).await?; + inner.shared.clear_loaded(&session)?; + Ok(()) +} + +pub(super) async fn drain( + pending: &mut Option, +) -> Result, RunError> { if let Some(Pending { cancellation, state, }) = pending.take() { cancellation.0.cancel(); - if let CandidateState::Running(worker) = state { - join_worker(worker).await?; + match state { + CandidateState::Running(worker) => return join_worker(worker).await, + CandidateState::Ready(checkpoint) => return Ok(Some(*checkpoint)), } } - Ok(()) + Ok(None) } diff --git a/plugins/agent/engine/src/steps.rs b/plugins/agent/engine/src/steps.rs index 863e2d46300..1b30fa02796 100644 --- a/plugins/agent/engine/src/steps.rs +++ b/plugins/agent/engine/src/steps.rs @@ -466,7 +466,7 @@ async fn run_from( } Ok(model_attempt::Outcome::Response(output)) => output, Ok(model_attempt::Outcome::ParkRetry(plan)) => { - auto_context::drain(&mut pending).await?; + let _ = auto_context::drain(&mut pending).await?; if !tools.is_idle() || !effects.is_idle() { return Err(RunError::ReconciliationRequired( "model retry cannot park while execution remains active".into(), @@ -647,7 +647,7 @@ async fn run_from( return Err(crate::cleanup::unconfirmed()); } tool_cleanup?; - summary_cleanup?; + let checkpoint = summary_cleanup?; if matches!( result, Ok(StepExit::Terminal( @@ -681,6 +681,9 @@ async fn run_from( } error => RunError::Store(error), })?; + if let Some(checkpoint) = checkpoint { + auto_context::record(inner, checkpoint).await?; + } } result } diff --git a/plugins/agent/engine/tests/integration/auto_context/background.rs b/plugins/agent/engine/tests/integration/auto_context/background.rs index d3e3fea1bb5..e81a3d894ab 100644 --- a/plugins/agent/engine/tests/integration/auto_context/background.rs +++ b/plugins/agent/engine/tests/integration/auto_context/background.rs @@ -236,7 +236,19 @@ async fn background_summary_preserves_steering_tool_tail_and_frozen_physical_ret #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn background_summary_drains_before_terminal_handoff_and_retirement() { - for ending in ["complete", "cancel", "handoff", "retire", "summary-tool"] { + use crate::support::code_mode_lifetime::{self, AnswerEffect}; + use maka_tools::{ + ToolCatalog, ToolHandler, ToolMode, ToolNesting, ToolRegistration, ToolSemantics, + }; + + for ending in [ + "finish-ready", + "complete", + "cancel", + "handoff", + "retire", + "summary-tool", + ] { tokio::time::timeout(Duration::from_secs(20), async { use tokio::io::AsyncReadExt; let directory = tempfile::tempdir().unwrap(); @@ -246,9 +258,28 @@ async fn background_summary_drains_before_terminal_handoff_and_retirement() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let base = format!("http://{}/v1", listener.local_addr().unwrap()); let count = Arc::new(AtomicUsize::new(0)); - let mut next = agent_loop::input(&base, "next", Arc::new(agent_loop::Effect { + let effect = Arc::new(agent_loop::Effect { log: log.clone(), count: count.clone(), - })); + }); + let mut next = agent_loop::input(&base, "next", effect.clone()); + let slow = Arc::new(AnswerEffect::default()); + if ending == "finish-ready" { + let RunWork::Message { tools, .. } = &mut next.work else { unreachable!() }; + *tools = ToolCatalog::new([ + ToolRegistration { + definition: serde_json::from_value(json!({"name":"slow","description":"controlled answer","inputSchema":{"type":"object"}})).unwrap(), + nesting: ToolNesting::Nestable, semantics: ToolSemantics::Parallel, + handler: ToolHandler::Immediate(slow.clone()), + }, + ToolRegistration { + definition: serde_json::from_value(json!({"name":"echo","description":"finish this turn","inputSchema":{"type":"object"}})).unwrap(), + nesting: ToolNesting::DirectOnly, semantics: ToolSemantics::FinishTurn, + handler: ToolHandler::Immediate(effect.clone()), + }, + ]).unwrap(); + next.configuration.tool_mode = ToolMode::CodeMode; + } + drop(effect); next.configuration.model = fixture::input(&base, "unused", false).configuration.model; next.configuration.workspace_identity = Some(maka_agent_facts::execution::WorkspaceIdentity::from_marker_id( "ef751105-55b5-4d65-a364-646281586a17").unwrap()); @@ -266,7 +297,7 @@ async fn background_summary_drains_before_terminal_handoff_and_retirement() { let (mut socket, _) = listener.accept().await.unwrap(); let request = fixture::read_request(&mut socket).await; if request["messages"].to_string().contains(maka_agent_facts::context::default_compaction_prompt(maka_agent_facts::language::Language::En).lines().next().unwrap()) { - if ending != "summary-tool" { + if !matches!(ending, "summary-tool" | "finish-ready") { socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n").await.unwrap(); let delta = json!({"id":"summary","object":"chat.completion.chunk","created":1,"model":"test", "choices":[{"index":0,"delta":{"content":"private unfinished summary"},"finish_reason":null}]}); @@ -278,10 +309,20 @@ async fn background_summary_drains_before_terminal_handoff_and_retirement() { if ending == "summary-tool" { agent_loop::respond(summary.as_mut().unwrap(), true).await; } + if ending == "finish-ready" { + code_mode_lifetime::tool(main.as_mut().unwrap(), "exec", + json!({"code":"await tools.slow({});","yield_time_ms":0})).await; + let (mut socket, _) = listener.accept().await.unwrap(); + let request = fixture::read_request(&mut socket).await; + assert!(request["messages"].to_string().contains("running")); + slow.entered.notified().await; + main = Some(socket); + fixture::respond(summary.as_mut().unwrap(), fixture::SUMMARY, "stop").await; + } seen.send(()).unwrap(); released.await.unwrap(); let mut main = main.unwrap(); - if !matches!(ending, "cancel" | "retire") { agent_loop::respond(&mut main, ending == "handoff").await; } + if !matches!(ending, "cancel" | "retire") { agent_loop::respond(&mut main, matches!(ending, "handoff" | "finish-ready")).await; } let _ = summary.unwrap().read_to_end(&mut Vec::new()).await; }); let worker = engine(); @@ -290,12 +331,14 @@ async fn background_summary_drains_before_terminal_handoff_and_retirement() { worker.run(log.store(), old, CancellationToken::new()).await.unwrap(); let running = worker.start(log.store(), next, CancellationToken::new()).await.unwrap(); ready.await.unwrap(); - if ending == "summary-tool" { + if matches!(ending, "summary-tool" | "finish-ready") { let mut commits = log.subscribe_commits(); loop { let prefix = log.prefix(200, 512 * 1024).await.unwrap(); if prefix.events.iter().any(|e| matches!(&e.event.fact, - Fact::ModelInterrupted { partial: None, .. })) { break; } + Fact::ModelInterrupted { partial: None, .. } if ending == "summary-tool") + || matches!(&e.event.fact, Fact::ModelCompleted { output, .. } + if ending == "finish-ready" && serde_json::to_string(output).unwrap().contains("summary-marker"))) { break; } commits.changed().await.unwrap(); } } @@ -326,9 +369,11 @@ async fn background_summary_drains_before_terminal_handoff_and_retirement() { Fact::ModelRequested { step_id, purpose: ModelPurpose::Summary, .. } => Some(step_id), _ => None, }).unwrap(); - assert!(prefix.events.iter().any(|event| matches!(&event.event.fact, - Fact::ModelInterrupted { step_id, partial: None, .. } if step_id == summary_step)), - "cancel/drain seals Summary without publishing its private prose"); + if ending != "finish-ready" { + assert!(prefix.events.iter().any(|event| matches!(&event.event.fact, + Fact::ModelInterrupted { step_id, partial: None, .. } if step_id == summary_step)), + "cancel/drain seals Summary without publishing its private prose"); + } if ending == "summary-tool" { assert!(!prefix.events.iter().any(|event| matches!(&event.event.fact, Fact::ModelObserved { step_id, event: maka_agent_facts::model::recorded::ModelObservation::Part(_) } @@ -346,13 +391,18 @@ async fn background_summary_drains_before_terminal_handoff_and_retirement() { } assert!(rows.iter().all(|row| !serde_json::to_string(&row.message).unwrap().contains("private unfinished summary"))); assert!(log.unfinished_invocations(0, 10).await.unwrap().invocations.is_empty()); - assert!(log.read_model_view("session", None).await.unwrap().baseline.is_none()); - assert_eq!(count.load(Ordering::SeqCst), usize::from(ending == "handoff")); + assert_eq!(log.read_model_view("session", None).await.unwrap().baseline.is_some(), ending == "finish-ready"); + assert_eq!(count.load(Ordering::SeqCst), usize::from(matches!(ending, "handoff" | "finish-ready"))); if ending == "retire" { assert!(log.root().session_retirement_ready("session").await.unwrap()); } drop(worker); - Arc::try_unwrap(log).ok().unwrap().close().await.unwrap(); + log.shutdown().await.unwrap(); + drop(log); let log = SessionFixture::open(&path).await.unwrap(); assert_eq!(log.root().recover_invocations().await.unwrap(), 0); + if ending == "finish-ready" { + let baseline = log.read_model_view("session", None).await.unwrap().baseline.unwrap(); + assert_eq!(baseline.checkpoint.summary.text_value().unwrap(), fixture::SUMMARY); + } log.close().await.unwrap(); }).await.expect("summary must drain before the invocation seals"); }