diff --git a/Cargo.lock b/Cargo.lock index 79e2c214..64a8d106 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -55,9 +55,9 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "0.14.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efba6592048ef8a9ac97de8d79b2d9933d8ac4d94f7a2de102348fed0c61103" +checksum = "882b815ffa67b023e1b40e7ea3bab3f05869654e8565d6811870c5545285e1d3" dependencies = [ "agent-client-protocol-derive", "agent-client-protocol-schema", @@ -65,7 +65,6 @@ dependencies = [ "blocking", "futures", "futures-concurrency", - "jsonrpcmsg", "rustc-hash 2.1.3", "schemars 1.2.1", "serde", @@ -73,13 +72,14 @@ dependencies = [ "shell-words", "tracing", "uuid", + "windows-sys 0.61.2", ] [[package]] name = "agent-client-protocol-derive" -version = "0.14.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d176a10d4cb06e0262a738c3c5bf21ff0968db13a666e31cbca94a3d3d72e7c" +checksum = "dd5ca63f112bd2459bcaf9eda0683b9ba95fc3b5e5fdd9036ca941c6a09345b1" dependencies = [ "quote", "syn", @@ -87,9 +87,9 @@ dependencies = [ [[package]] name = "agent-client-protocol-schema" -version = "0.13.6" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c290bfa00c6b52339db66f8e9cf711d5f08530800529f7d619ff24d6cba253d0" +checksum = "06679e1542356341f4550ccfb16338b64f37f6af70de2105446ef6fbb078234c" dependencies = [ "anyhow", "derive_more", @@ -675,7 +675,7 @@ dependencies = [ "rayon", "safetensors", "thiserror 2.0.18", - "tokenizers", + "tokenizers 0.22.2", "yoke", "zip", ] @@ -1217,6 +1217,12 @@ dependencies = [ "cipher", ] +[[package]] +name = "daachorse" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f55d7153ba3b507595872a3874803f07a8a81d1e888abed8e5db7da0597d6e2" + [[package]] name = "darling" version = "0.20.11" @@ -2806,16 +2812,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "jsonrpcmsg" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d833a15225c779251e13929203518c2ff26e2fe0f322d584b213f4f4dad37bd" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "jsonschema" version = "0.46.10" @@ -3137,9 +3133,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "minimad" -version = "0.14.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8b688969b16915f3ecadc7829d5b7779dee4977e503f767f34136803d5c06f" +checksum = "de632ee829aec3a874d18a4192eae64a0460b3a45c54ed556b334f6fe5a1d62f" dependencies = [ "once_cell", ] @@ -3442,7 +3438,7 @@ dependencies = [ "sha2 0.10.9", "thiserror 2.0.18", "tiktoken-rs 0.9.1", - "tokenizers", + "tokenizers 0.22.2", "tokio", "tracing", ] @@ -3503,7 +3499,7 @@ dependencies = [ "termimad", "thiserror 2.0.18", "tiktoken-rs 0.12.0", - "tokenizers", + "tokenizers 0.23.1", "tokio", "tokio-tungstenite", "tokio-util", @@ -4123,9 +4119,9 @@ dependencies = [ [[package]] name = "reedline" -version = "0.48.0" +version = "0.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "201e8e0160cbe7bb5eb2caccf281e178e77fac95115ab31a2c29edc5593603c8" +checksum = "826c1fc22a2b1f14c3f6a80fc3d56adfbfb913d07f170bce4246700de7adfd50" dependencies = [ "chrono", "crossbeam", @@ -4314,9 +4310,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.8.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" +checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af" dependencies = [ "async-trait", "chrono", @@ -4645,6 +4641,7 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -4752,13 +4749,13 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.7" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -5188,9 +5185,9 @@ dependencies = [ [[package]] name = "termimad" -version = "0.34.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "889a9370996b74cf46016ce35b96c248a9ac36d69aab1d112b3e09bc33affa49" +checksum = "d53d4b1294b87e81925b7ae7f8f4d000376e3a5b3349d978429665428a793fcb" dependencies = [ "coolor", "crokey", @@ -5384,6 +5381,39 @@ dependencies = [ "unicode_categories", ] +[[package]] +name = "tokenizers" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44e5bea67576e04b6ff8564c5d9e09c2ef0cf476502245f2f120e497769d3112" +dependencies = [ + "ahash", + "compact_str", + "daachorse", + "dary_heap", + "derive_builder", + "esaxx-rs", + "fancy-regex 0.17.0", + "getrandom 0.3.4", + "itertools 0.14.0", + "log", + "macro_rules_attribute", + "monostate", + "paste", + "rand 0.9.5", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + [[package]] name = "tokio" version = "1.52.3" @@ -5423,9 +5453,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.29.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" dependencies = [ "futures-util", "log", @@ -5619,16 +5649,16 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.29.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" dependencies = [ "bytes", "data-encoding", "http", "httparse", "log", - "rand 0.9.5", + "rand 0.10.2", "sha1", "thiserror 2.0.18", ] diff --git a/Cargo.toml b/Cargo.toml index 861f642f..6ea89753 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,35 +34,36 @@ overflow-checks = false # Disable overflow checks in release mode [dependencies] # octolib = { path = "../octolib", default-features = false, features = ["huggingface"] } octolib = { version = "0.25.1", default-features = false, features = ["huggingface"] } -tokenizers = { version = "0.22.2", default-features = false } # model's own tokenizer for exact token counts -rmcp = { version = "1.7.0", default-features = false } +# fancy-regex: 0.23+ requires an explicit regex backend; pure-Rust over onig (C) +tokenizers = { version = "0.23.1", default-features = false, features = ["fancy-regex"] } # model's own tokenizer for exact token counts +rmcp = { version = "2.2.0", default-features = false } tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "time", "process", "fs", "sync", "signal", "io-std", "net"] } crossterm = "0.29.0" -indicatif = "0.18.4" +indicatif = "0.18.6" parking_lot = "0.12.5" chrono = { version = "0.4.45", features = ["serde"] } serde = { version = "1.0.228", features = ["derive"] } -uuid = { version = "1.23.3", features = ["v4"] } +uuid = { version = "1.23.4", features = ["v4"] } tiktoken-rs = "0.12.0" # Token counter reqwest = { version = "0.13.4", features = ["json", "rustls", "gzip"], default-features = false } rustls-webpki = ">=0.103.12" flate2 = "1.1" zstd = "0.13" -anyhow = "1.0.102" +anyhow = "1.0.103" serde_json = "1.0.150" clap = { version = "4.6.1", features = ["derive"] } -clap_complete = "4.6.5" +clap_complete = "4.6.7" toml = "1.1" lazy_static = "1.5.0" fuzzy-matcher = "0.3.7" tokio-util = { version = "0.7", features = ["rt", "compat"] } -reedline = { version = "0.48", features = ["bashisms", "external_printer"] } +reedline = { version = "0.49", features = ["bashisms", "external_printer"] } nu-ansi-term = "0.50" -regex = "1.12.3" +regex = "1.13.0" colored = "3.1.1" async-trait = "0.1.89" -termimad = "0.34.1" +termimad = "0.35.1" # No plist-load/yaml-load: only bundled dumps are used (ThemeSet::load_defaults), # and plist pulls quick-xml with open RUSTSEC advisories (2026-0194/0195). syntect = { version = "5.3.0", default-features = false, features = ["default-syntaxes", "default-themes", "regex-fancy"] } @@ -70,7 +71,7 @@ syntect = { version = "5.3.0", default-features = false, features = ["default-sy url = "2.5.8" dirs = "6.0.0" glob = "0.3.3" -ignore = "0.4.26" +ignore = "0.4.28" futures = "0.3.32" dotenvy = "0.15.7" arboard = "3.6.1" @@ -79,7 +80,7 @@ viuer = "0.11.0" base64 = "0.22" urlencoding = "2.1.3" humantime-serde = "1.1.1" -tokio-tungstenite = "0.29.0" +tokio-tungstenite = "0.30.0" futures-util = "0.3" # OAuth 2.1 + PKCE authentication @@ -98,9 +99,9 @@ hyper = { version = "1", features = ["http1", "server"] } hyper-util = { version = "0.1", features = ["tokio"] } http-body-util = "0.1" bytes = "1" -agent-client-protocol = "0.14.0" +agent-client-protocol = "1.2.0" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } -which = "8.0.3" +which = "8.0.4" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/config-templates/default.toml b/config-templates/default.toml index abec2663..c2476465 100644 --- a/config-templates/default.toml +++ b/config-templates/default.toml @@ -659,7 +659,8 @@ enabled = true model = "anthropic:claude-haiku-4-5" # Evidence-bound claims: have the agent back load-bearing repo facts with a # verbatim «quote», then deterministically verify each quote occurs in a tool -# result; fabricated citations are re-grounded via the verify-gate (needs gate.enabled). +# result -- and that each file:line reference in the answer holds on disk; +# fabricated citations are re-grounded via the verify-gate (needs gate.enabled). claim_check = true # Circuit-breaker: hard-stop a turn after this many consecutive tool rounds that emitted # a steer without the model breaking out (a steer is advisory, so a loop can otherwise @@ -733,6 +734,10 @@ verifier_model = "anthropic:claude-haiku-4-5" # Free deterministic pre-gate (no model call): refuse a `done` claim when code # was changed but no successful check (build/test/lint/etc.) ran since the change. require_check_after_mutation = true +# Free deterministic pre-gate (no model call): refuse a `done` claim while the +# live plan checklist still has open items -- finish them or close them out via +# the plan tool (mark complete / done / reset) first. +require_plan_complete = true # Goal recitation: re-inject the live goal (anchor intent + next_steps) at the # context tail each turn on long (already-compacted) sessions, so it stays in the diff --git a/src/acp/agent.rs b/src/acp/agent.rs index 6eb5ad71..5ad27fc9 100644 --- a/src/acp/agent.rs +++ b/src/acp/agent.rs @@ -19,16 +19,17 @@ use std::collections::HashMap; use std::path::PathBuf; use std::rc::Rc; -use agent_client_protocol::schema::{ +use agent_client_protocol::schema::v1::{ AgentCapabilities, AuthenticateRequest, AuthenticateResponse, AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, BlobResourceContents, CancelNotification, ClientRequest, ContentBlock, ContentChunk, EmbeddedResourceResource, ExtRequest, ExtResponse, Implementation, InitializeRequest, InitializeResponse, LoadSessionRequest, LoadSessionResponse, McpCapabilities, McpServer, Meta, NewSessionRequest, NewSessionResponse, PromptCapabilities, - PromptRequest, PromptResponse, ProtocolVersion, SessionInfoUpdate, SessionNotification, - SessionUpdate, StopReason, ToolCall, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, + PromptRequest, PromptResponse, SessionInfoUpdate, SessionNotification, SessionUpdate, + StopReason, ToolCall, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, UnstructuredCommandInput, }; +use agent_client_protocol::schema::ProtocolVersion; use agent_client_protocol::{ByteStreams, Client, ConnectionTo, Responder}; use tokio::sync::{mpsc, oneshot}; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; @@ -1325,8 +1326,9 @@ impl OctomindAgent { /// request carries a `oneshot` for its typed reply. pub(super) enum Command { SetConnection(ConnectionTo), + // Boxed: ACP 1.x InitializeRequest is ~600 bytes, dwarfing every other variant. Initialize( - InitializeRequest, + Box, oneshot::Sender>, ), Authenticate( @@ -1367,7 +1369,7 @@ async fn run_actor(agent: Rc, mut rx: mpsc::UnboundedReceiver { let agent = Rc::clone(&agent); tokio::task::spawn_local(async move { - let _ = reply.send(agent.initialize(req).await); + let _ = reply.send(agent.initialize(*req).await); }); } Command::Authenticate(req, reply) => { @@ -1465,7 +1467,7 @@ pub(super) async fn serve( let cmd_tx = cmd_tx.clone(); async move |req: InitializeRequest, responder, cx: ConnectionTo| { forward(&cmd_tx, &cx, responder, move |tx| { - Command::Initialize(req, tx) + Command::Initialize(Box::new(req), tx) }) } }, diff --git a/src/acp/commands.rs b/src/acp/commands.rs index 6bdfb597..94f1dd46 100644 --- a/src/acp/commands.rs +++ b/src/acp/commands.rs @@ -17,7 +17,7 @@ use std::cell::RefCell; use std::rc::Rc; -use agent_client_protocol::schema::{ExtRequest, ExtResponse}; +use agent_client_protocol::schema::v1::{ExtRequest, ExtResponse}; use agent_client_protocol::Error; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; diff --git a/src/mcp/core/plan/core.rs b/src/mcp/core/plan/core.rs index ec4abec6..b9a192c5 100644 --- a/src/mcp/core/plan/core.rs +++ b/src/mcp/core/plan/core.rs @@ -902,6 +902,30 @@ pub fn render_plan_checklist() -> Option { Some(s) } +/// Titles of not-yet-completed tasks. Pure filter so it is testable without +/// touching the process-global plan storage. +fn open_titles(task_list: Vec<(String, String, TaskStatus)>) -> Vec { + task_list + .into_iter() + .filter(|(_, _, status)| !matches!(status, TaskStatus::Completed)) + .map(|(title, _, _)| title) + .collect() +} + +/// Free pre-gate signal: titles of open items in the active plan. Empty when +/// no active plan or every task is completed. +pub fn open_plan_tasks() -> Vec { + let storage = get_storage(); + let storage = storage.lock().unwrap(); + if !storage.has_active_plan().unwrap_or(false) { + return Vec::new(); + } + match storage.get_task_list() { + Ok(list) => open_titles(list), + Err(_) => Vec::new(), + } +} + /// Get current plan display for session commands pub async fn get_current_plan_display() -> Result { let storage = get_storage(); @@ -998,3 +1022,27 @@ pub async fn get_current_plan_json() -> Result { }).collect::>() })) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn open_titles_keeps_only_uncompleted() { + let list = vec![ + ("done one".to_string(), String::new(), TaskStatus::Completed), + ( + "open one".to_string(), + String::new(), + TaskStatus::InProgress, + ), + ( + "open two".to_string(), + String::new(), + TaskStatus::InProgress, + ), + ]; + assert_eq!(open_titles(list), vec!["open one", "open two"]); + assert!(open_titles(vec![]).is_empty()); + } +} diff --git a/src/mcp/core/plan/mod.rs b/src/mcp/core/plan/mod.rs index cc35c793..5b6bb80c 100644 --- a/src/mcp/core/plan/mod.rs +++ b/src/mcp/core/plan/mod.rs @@ -39,8 +39,8 @@ pub use compression::{ pub use core::{ clear_plan_data, clear_task_start_index, execute_plan, get_and_clear_start_index, get_completed_task_count, get_current_plan_display, get_current_task_start_index, - get_last_completed_task_for_compression, has_active_plan, render_plan_checklist, - set_current_task_start_index, set_last_task_message_range, + get_last_completed_task_for_compression, has_active_plan, open_plan_tasks, + render_plan_checklist, set_current_task_start_index, set_last_task_message_range, }; pub use memory_storage::MemoryPlanStorage; pub use storage::{ExecutionPlan, MessageRange, PlanStatus, PlanStorage, PlanTask, TaskStatus}; diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index bbedf73f..878fa9fe 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -85,7 +85,9 @@ impl McpToolResult { Self { tool_name, tool_id, - result: rmcp::model::CallToolResult::success(vec![rmcp::model::Content::text(content)]), + result: rmcp::model::CallToolResult::success(vec![rmcp::model::ContentBlock::text( + content, + )]), } } @@ -101,7 +103,9 @@ impl McpToolResult { tool_id, result: { let mut r = - rmcp::model::CallToolResult::success(vec![rmcp::model::Content::text(content)]); + rmcp::model::CallToolResult::success(vec![rmcp::model::ContentBlock::text( + content, + )]); r.structured_content = Some(metadata); r }, @@ -113,7 +117,7 @@ impl McpToolResult { Self { tool_name, tool_id, - result: rmcp::model::CallToolResult::error(vec![rmcp::model::Content::text( + result: rmcp::model::CallToolResult::error(vec![rmcp::model::ContentBlock::text( error_message, )]), } @@ -126,13 +130,13 @@ impl McpToolResult { // Extract plain text content from all text items in the result pub fn extract_content(&self) -> String { - use rmcp::model::RawContent; + use rmcp::model::ContentBlock; let main_content = self .result .content .iter() - .filter_map(|item| match &item.raw { - RawContent::Text(t) => Some(t.text.as_str()), + .filter_map(|item| match item { + ContentBlock::Text(t) => Some(t.text.as_str()), _ => None, }) .collect::>() diff --git a/src/mcp/server.rs b/src/mcp/server.rs index eed3c34e..d8cbf889 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -884,7 +884,7 @@ async fn execute_tool_with_cancellation( output, ) .unwrap_or_else(|_| { - rmcp::model::CallToolResult::success(vec![rmcp::model::Content::text( + rmcp::model::CallToolResult::success(vec![rmcp::model::ContentBlock::text( "No result", )]) }); diff --git a/src/session/anchor.rs b/src/session/anchor.rs index 0a320619..ee77d0a7 100644 --- a/src/session/anchor.rs +++ b/src/session/anchor.rs @@ -113,12 +113,15 @@ impl Anchor { /// Merge an update into this anchor. Append-only fields (`changes_made`, /// `decisions`, `file_refs`, `errors_seen`) are deduplicated on insert. - /// `intent` is filled in on first set; subsequent updates only refine - /// it if it was empty. `next_steps` always replaces (latest wins). + /// `intent` replaces when the update supplies one — suppliers decide when + /// that is right (most guard with `is_empty()` so the first write sticks; + /// conversation compaction supplies the summarizer's ORIGINAL REQUEST, + /// which carries forward verbatim and moves only on an explicit user + /// pivot, so a stale goal can heal). `next_steps` always replaces. pub fn extend(&mut self, update: AnchorUpdate, now_unix: u64) { if let Some(intent) = update.intent { let trimmed = intent.trim(); - if !trimmed.is_empty() && self.intent.is_empty() { + if !trimmed.is_empty() { self.intent = trimmed.to_string(); } } @@ -198,7 +201,7 @@ mod tests { } #[test] - fn extend_records_first_intent_only() { + fn extend_replaces_intent_when_supplied() { let mut a = Anchor::default(); a.extend( AnchorUpdate { @@ -207,6 +210,10 @@ mod tests { }, 100, ); + // No intent in the update — the existing one is kept. + a.extend(AnchorUpdate::default(), 150); + assert_eq!(a.intent, "Add feature X"); + // A supplied intent replaces (pivot sanctioned by the supplier). a.extend( AnchorUpdate { intent: Some("Now do feature Y instead".to_string()), @@ -214,9 +221,8 @@ mod tests { }, 200, ); - // First-write-wins for intent — later updates don't overwrite. - assert_eq!(a.intent, "Add feature X"); - assert_eq!(a.compactions_folded, 2); + assert_eq!(a.intent, "Now do feature Y instead"); + assert_eq!(a.compactions_folded, 3); assert_eq!(a.last_compacted_at, 200); } diff --git a/src/session/chat/conversation_compression/apply.rs b/src/session/chat/conversation_compression/apply.rs index 311325ef..923cf058 100644 --- a/src/session/chat/conversation_compression/apply.rs +++ b/src/session/chat/conversation_compression/apply.rs @@ -398,10 +398,18 @@ pub(super) async fn apply_compression( .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); - let intent_seed = if session.session.info.anchor.intent.is_empty() { - Some("Free-form conversation session".to_string()) - } else { - None + // Prefer the summarizer's ORIGINAL REQUEST (verbatim user words, carried + // forward across compactions, refreshed only on an explicit user pivot) + // so the anchor goal tracks the real task instead of going stale. + let intent_seed = { + let orig = summary.original_request.trim(); + if !orig.is_empty() { + Some(orig.to_string()) + } else if session.session.info.anchor.intent.is_empty() { + Some("Free-form conversation session".to_string()) + } else { + None + } }; session.session.info.anchor.extend( crate::session::anchor::AnchorUpdate { diff --git a/src/session/chat/conversation_compression/prompt.rs b/src/session/chat/conversation_compression/prompt.rs index 3c76fadc..024cc727 100644 --- a/src/session/chat/conversation_compression/prompt.rs +++ b/src/session/chat/conversation_compression/prompt.rs @@ -134,7 +134,7 @@ fn build_compression_prompt( If the transcript contains a prior block, treat its content as established facts that must carry forward: -- original_request: copy from the prior summary unchanged. Otherwise quote verbatim from the very first user turn. +- original_request: copy from the prior summary unchanged. Otherwise quote verbatim from the very first user turn. Exception: if the user explicitly abandoned that task for an unrelated one, quote the pivot request verbatim instead. - analysis_findings, errors_and_corrections, critical_knowledge: carry forward all prior entries, append new ones. - progress: extend (do not replace) the prior progress narrative. - current_task, next_steps: replace based on the most recent transcript. diff --git a/src/session/chat/conversation_compression/schema.rs b/src/session/chat/conversation_compression/schema.rs index 9615822f..24c56619 100644 --- a/src/session/chat/conversation_compression/schema.rs +++ b/src/session/chat/conversation_compression/schema.rs @@ -187,7 +187,7 @@ pub fn build_compression_schema(force: bool) -> serde_json::Value { }, "original_request": { "type": "string", - "description": "The user's original task statement. Quote verbatim from the very first user turn in the transcript; OR, if a prior **ORIGINAL REQUEST** exists in a previous summary inside the transcript, carry it forward unchanged. Never paraphrase." + "description": "The user's original task statement. Quote verbatim from the very first user turn in the transcript; OR, if a prior **ORIGINAL REQUEST** exists in a previous summary inside the transcript, carry it forward unchanged. EXCEPTION: if the user has since explicitly abandoned that task for an unrelated one, quote the pivot request verbatim instead — the abandoned task must not linger as the goal. Never paraphrase." }, "session_context": { "type": "string", diff --git a/src/session/chat/response.rs b/src/session/chat/response.rs index da73112f..baa10974 100644 --- a/src/session/chat/response.rs +++ b/src/session/chat/response.rs @@ -653,6 +653,19 @@ pub async fn process_response( is_error, result_content.len(), ); + // Ground truth for the gate: keep the last successful shell + // output — the decisive check normally runs right before `done`. + if call.tool_name == "shell" && !is_error { + let cmd = call + .parameters + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + params + .chat_session + .evidence + .record_command_output(cmd, &result_content); + } // Tool-agnostic: detect truncation by the sentinel the global // truncation choke point stamps, not by tool identity. let is_truncated = result_content diff --git a/src/session/chat/response/tool_execution.rs b/src/session/chat/response/tool_execution.rs index ff363f67..4a8ca18a 100644 --- a/src/session/chat/response/tool_execution.rs +++ b/src/session/chat/response/tool_execution.rs @@ -725,7 +725,7 @@ async fn handle_large_tool_results( // identical failure — violating the "errors are never deduped" // invariant exactly when the model needs the error text most. let was_error = result.is_error(); - let content = vec![rmcp::model::Content::text(truncated)]; + let content = vec![rmcp::model::ContentBlock::text(truncated)]; result.result = if was_error { rmcp::model::CallToolResult::error(content) } else { diff --git a/src/session/chat/session/api_executor.rs b/src/session/chat/session/api_executor.rs index 505f1709..cf3b9f42 100644 --- a/src/session/chat/session/api_executor.rs +++ b/src/session/chat/session/api_executor.rs @@ -360,9 +360,57 @@ pub async fn execute_api_call_and_process_response( // Budget exhausted — fall through to the LLM gate / acceptance. } + // Free plan pre-gate (no model call): a self-reported `done` while the live + // plan still has open items is drift-by-omission — parts of the decomposed + // task silently dropped. The agent must finish them or close them out via + // the plan tool. Same marker/budget pattern as the mutation pre-gate above. + if config.supervisor.gate.require_plan_complete { + let open = crate::mcp::core::plan::open_plan_tasks(); + let already_nudged_plan = { + let msgs = &chat_session.session.messages; + let turn_start = msgs + .iter() + .rposition(crate::session::is_real_user_task_message) + .unwrap_or(0); + msgs[turn_start..].iter().any(|m| { + m.content + .contains(crate::supervisor::gate::PLAN_GATE_MARKER) + }) + }; + if !open.is_empty() && !already_nudged_plan { + let note = crate::supervisor::gate::format_plan_advisory(&open); + chat_session.add_system_managed_user_message(¬e)?; + chat_session.last_self_report = None; // force the re-run to re-evaluate + chat_session.gate_iterations += 1; + crate::supervisor::stats::plan_block(); + crate::supervisor::notify(&format!( + "done claimed with {} open plan item(s) — re-running", + open.len() + )); + if chat_session.gate_iterations < config.supervisor.gate.max_iterations { + crate::log_debug!( + "Plan pre-gate: {} open item(s); re-running turn (iter {})", + open.len(), + chat_session.gate_iterations + ); + return Box::pin(execute_api_call_and_process_response( + chat_session, + config, + role, + operation_rx, + mode, + sink, + )) + .await; + } + // Budget exhausted — fall through to the LLM gate / acceptance. + } + } + // Free evidence check (no model call): a `done` answer that cites « » quotes - // which appear in NO tool result is fabricating its support. Catch it - // deterministically and re-ground via the same bounded re-run. + // which appear in NO tool result, or `file:line` references that do not + // hold on disk, is fabricating its support. Catch both deterministically + // and re-ground via the same bounded re-run. if config.supervisor.claim_check { let tool_outputs: Vec = chat_session .session @@ -375,14 +423,29 @@ pub async fn execute_api_call_and_process_response( &chat_session.last_response, &tool_outputs, ); - if !unverified.is_empty() { - let mut note = String::from( - "\nEach quote below was presented as «verbatim» from a tool result, but none string-matches any output you received — so it is unsupported. For each, go back to the actual tool output (not your earlier answer): copy the exact lines that support the claim, then restate the claim from them. If no tool output contains them, say so and drop that claim — \"not found in tool output\" is the correct answer here; never invent a source. Unsupported quotes:\n", - ); - for q in &unverified { - note.push_str("- «"); - note.push_str(q); - note.push_str("»\n"); + let bad_refs = + crate::supervisor::detect::unverified_file_refs(&chat_session.last_response); + if !unverified.is_empty() || !bad_refs.is_empty() { + let mut note = String::from("\n"); + if !unverified.is_empty() { + note.push_str( + "Each quote below was presented as «verbatim» from a tool result, but none string-matches any output you received — so it is unsupported. For each, go back to the actual tool output (not your earlier answer): copy the exact lines that support the claim, then restate the claim from them. If no tool output contains them, say so and drop that claim — \"not found in tool output\" is the correct answer here; never invent a source. Unsupported quotes:\n", + ); + for q in &unverified { + note.push_str("- «"); + note.push_str(q); + note.push_str("»\n"); + } + } + if !bad_refs.is_empty() { + note.push_str( + "Each file:line reference below does not hold on disk — the file is missing or the line is beyond its end. Re-check the real location and cite the correct file and line; if the reference was illustrative or the file was intentionally deleted, say so instead of citing it as a location. Invalid references:\n", + ); + for r in &bad_refs { + note.push_str("- "); + note.push_str(r); + note.push('\n'); + } } note.push_str(""); chat_session.add_system_managed_user_message(¬e)?; @@ -390,13 +453,15 @@ pub async fn execute_api_call_and_process_response( chat_session.gate_iterations += 1; crate::supervisor::stats::claim_block(); crate::supervisor::notify(&format!( - "{} unverifiable citation(s) — re-running", - unverified.len() + "{} unverifiable citation(s), {} invalid file reference(s) — re-running", + unverified.len(), + bad_refs.len() )); if chat_session.gate_iterations < config.supervisor.gate.max_iterations { crate::log_debug!( - "Evidence check: {} unverified citation(s); re-running (iter {})", + "Evidence check: {} unverified citation(s), {} bad file ref(s); re-running (iter {})", unverified.len(), + bad_refs.len(), chat_session.gate_iterations ); return Box::pin(execute_api_call_and_process_response( @@ -426,14 +491,33 @@ pub async fn execute_api_call_and_process_response( let result = chat_session.last_response.clone(); let claim = chat_session.last_self_report_reason.clone(); let actions = chat_session.evidence.render(); + // Durable goal + live plan for the verifier: a terse follow-up turn + // ("continue") is only verifiable against what it refers to. + let plan_checklist = crate::mcp::core::plan::render_plan_checklist(); + let context = crate::supervisor::gate::render_session_context( + &chat_session.session.info.anchor.intent, + plan_checklist.as_deref(), + ); + // Runtime-gathered ground truth: the diff of what actually changed and + // the last command's recorded output — the verifier judges state, not story. + let ground_truth = crate::supervisor::gate::render_ground_truth( + chat_session.evidence.mutated_paths(), + chat_session.evidence.last_command(), + ); + let prior_gaps = chat_session.last_gate_gaps.clone(); crate::supervisor::stats::gate_run(); animation_manager.set_phase("Verifying completion …").await; let verdict = crate::supervisor::gate::verify( config, - &task, - &result, - claim.as_deref(), - &actions, + crate::supervisor::gate::GateInput { + task: &task, + result: &result, + claim: claim.as_deref(), + actions: &actions, + context: &context, + ground_truth: &ground_truth, + prior_gaps: &prior_gaps, + }, operation_rx.clone(), ) .await; @@ -442,6 +526,7 @@ pub async fn execute_api_call_and_process_response( crate::supervisor::gate::GateVerdict::Pass => { chat_session.gate_iterations = 0; chat_session.gate_failed = false; + chat_session.last_gate_gaps.clear(); crate::supervisor::stats::gate_pass(); crate::log_debug!("Verify-gate: PASS"); crate::supervisor::notify("completion verified"); @@ -452,6 +537,7 @@ pub async fn execute_api_call_and_process_response( chat_session.add_system_managed_user_message(¬e)?; chat_session.last_self_report = None; // force the re-run to re-evaluate chat_session.gate_iterations += 1; + chat_session.last_gate_gaps = gaps.clone(); crate::log_debug!( "Verify-gate: {} gap(s); re-running turn (iter {})", gaps.len(), diff --git a/src/session/chat/session/commands/display.rs b/src/session/chat/session/commands/display.rs index ca42e478..e4e537d7 100644 --- a/src/session/chat/session/commands/display.rs +++ b/src/session/chat/session/commands/display.rs @@ -603,6 +603,7 @@ pub fn display_info(output: &CommandOutput) { let steers = get_u64("steers"); let pregate_blocks = get_u64("pregate_blocks"); let claim_blocks = get_u64("claim_blocks"); + let plan_blocks = get_u64("plan_blocks"); let lessons = get_u64("lessons_stored"); let orientation = get_u64("orientation_stored"); let recalls = get_u64("recalls_injected"); @@ -644,6 +645,9 @@ pub fn display_info(output: &CommandOutput) { if claim_blocks > 0 { activity.push(format!("{} claim-blocks", claim_blocks)); } + if plan_blocks > 0 { + activity.push(format!("{} plan-blocks", plan_blocks)); + } if lessons > 0 { activity.push(format!("{} lessons", lessons)); } diff --git a/src/session/chat/session/core.rs b/src/session/chat/session/core.rs index 26ac512a..e08a0512 100644 --- a/src/session/chat/session/core.rs +++ b/src/session/chat/session/core.rs @@ -205,6 +205,10 @@ pub struct ChatSession { /// Supervisor: set when the verify-gate exhausted retries with gaps remaining; /// suppresses distill so we never learn from an unverified trajectory. pub gate_failed: bool, + /// Supervisor: gaps the last verify-gate pass found this task, handed to the + /// next pass so it confirms each is closed instead of judging from scratch. + /// Cleared on PASS and on each genuine user turn. + pub last_gate_gaps: Vec, /// Supervisor: queued advisory steer note (loop / no-progress), injected at /// the next request's safe pre-build point. None = nothing to steer. pub steer_pending: Option, @@ -358,6 +362,7 @@ impl ChatSession { detectors: crate::supervisor::detect::Detectors::default(), gate_iterations: 0, gate_failed: false, + last_gate_gaps: Vec::new(), steer_pending: None, consecutive_steers: 0, steer_attempt: 0, @@ -569,6 +574,7 @@ impl ChatSession { detectors: crate::supervisor::detect::Detectors::default(), gate_iterations: 0, gate_failed: false, + last_gate_gaps: Vec::new(), steer_pending: None, consecutive_steers: 0, steer_attempt: 0, @@ -1321,6 +1327,7 @@ mod tests { detectors: crate::supervisor::detect::Detectors::default(), gate_iterations: 0, gate_failed: false, + last_gate_gaps: Vec::new(), steer_pending: None, consecutive_steers: 0, steer_attempt: 0, diff --git a/src/session/chat/session/messages.rs b/src/session/chat/session/messages.rs index 493dcba2..fb0a5c21 100644 --- a/src/session/chat/session/messages.rs +++ b/src/session/chat/session/messages.rs @@ -255,6 +255,7 @@ impl ChatSession { // only by a later PASS. self.evidence.reset(); self.gate_iterations = 0; + self.last_gate_gaps.clear(); // Reset the detector rolling windows (loop / no-progress / truncation / // dedup / drift streaks) so a new task doesn't inherit the previous task's // streaks. Trajectory state (unverified_mutation) is intentionally kept. diff --git a/src/supervisor/condense.rs b/src/supervisor/condense.rs index e496a436..578c4eab 100644 --- a/src/supervisor/condense.rs +++ b/src/supervisor/condense.rs @@ -336,7 +336,7 @@ fn apply_verdict(entry: &Entry, r: &McpToolResult, original: &str) -> Option String { s.split_whitespace().collect::>().join(" ") } +/// Deterministic evidence check: `file:line` references in `response` that do +/// not hold on disk — the file is missing, or the line number is beyond EOF. +/// High-precision by construction: only paths containing a `/` and an extension +/// starting with a letter are checked (bare `x.rs:3` or version-like `1.2:3` +/// never match), and URL interiors are excluded by the preceding-char guard. +/// Relative paths resolve against the process cwd (the project dir in a +/// session). No model call. +pub fn unverified_file_refs(response: &str) -> Vec { + static RE: std::sync::OnceLock = std::sync::OnceLock::new(); + let re = RE.get_or_init(|| { + regex::Regex::new( + r"(?:^|[^A-Za-z0-9_./:-])(/?(?:[A-Za-z0-9_@~.-]+/)+[A-Za-z0-9_.-]+\.[A-Za-z][A-Za-z0-9]{0,7}):([0-9]+)", + ) + .expect("static pattern") + }); + // Bound-check only files we can cheaply read as text. + const MAX_CHECKED_FILE: u64 = 2_000_000; + let mut flagged = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for cap in re.captures_iter(response) { + let path = &cap[1]; + let Ok(line) = cap[2].parse::() else { + continue; + }; + let key = format!("{path}:{line}"); + if !seen.insert(key.clone()) { + continue; + } + let p = std::path::Path::new(path); + if !p.exists() { + flagged.push(format!("{key} (file not found)")); + continue; + } + let Ok(meta) = p.metadata() else { + continue; + }; + if meta.len() > MAX_CHECKED_FILE { + continue; + } + let Ok(content) = std::fs::read_to_string(p) else { + continue; + }; + let count = content.lines().count(); + if line == 0 || line > count { + flagged.push(format!("{key} (file has only {count} lines)")); + } + } + flagged +} + /// Heuristic: does this tool change state, so a success is inherently progress? /// (Reads/searches only count as progress when they surface *new* content.) pub fn is_mutation_tool(tool: &str) -> bool { @@ -1103,6 +1153,39 @@ mod tests { assert!(unverified_citations("plain answer, no tags", &["x".to_string()]).is_empty()); } + #[test] + fn file_refs_existing_line_is_clean() { + // cargo test runs with the crate root as cwd, so this very file resolves. + assert!(unverified_file_refs("see src/supervisor/detect.rs:1 for it").is_empty()); + } + + #[test] + fn file_refs_missing_file_flagged() { + let bad = unverified_file_refs("fixed in src/supervisor/zz_no_such_file.rs:5 now"); + assert_eq!( + bad, + vec!["src/supervisor/zz_no_such_file.rs:5 (file not found)"] + ); + } + + #[test] + fn file_refs_line_beyond_eof_flagged() { + let bad = unverified_file_refs("look at src/supervisor/mod.rs:999999"); + assert_eq!(bad.len(), 1); + assert!(bad[0].starts_with("src/supervisor/mod.rs:999999 (file has only ")); + } + + #[test] + fn file_refs_urls_and_versions_not_matched() { + assert!(unverified_file_refs("see https://x.com/a/b.rs:12 and v1.2/3.4:56").is_empty()); + } + + #[test] + fn file_refs_deduplicated() { + let bad = unverified_file_refs("a/missing.rs:1 and again a/missing.rs:1 twice"); + assert_eq!(bad.len(), 1); + } + #[test] fn truncation_steers_even_while_exploring() { assert!(should_steer( diff --git a/src/supervisor/gate.rs b/src/supervisor/gate.rs index 1ea219e5..103c49be 100644 --- a/src/supervisor/gate.rs +++ b/src/supervisor/gate.rs @@ -36,6 +36,23 @@ outranks the narrative: - When RECORDED ACTIONS is absent or empty, the task may be pure reasoning — judge the result text on its own terms. +You may also receive SESSION CONTEXT — the durable goal this session is pursuing and/or the +live plan checklist. The request may be terse ("continue", "finish it", "fix that"): resolve +what it refers to using this context, and verify against the resolved meaning. An open plan +item is a gap only when the request (so resolved) asks for it — do not demand work beyond +the request's own scope. + +You may also receive GROUND TRUTH — runtime-gathered state (the working-tree diff of the +files the agent changed, current content of new files, and the last command's recorded +output). The agent cannot edit this either, and it outranks everything else: a claimed change +that does not appear in the diff is a gap; a file reported written but marked MISSING is a +gap; a "tests pass" claim is judged against the recorded command output, not the narrative. + +You may also receive PREVIOUSLY FLAGGED GAPS — gaps a prior verification pass found in this +same task. Check each one first: it must now be closed with concrete evidence, or credibly +rebutted as wrong or out of scope. A previously flagged gap that is neither closed nor +rebutted stays a gap. + Work through every part of the request, one at a time. For each, find the concrete proof it was done — a recorded action, file path, line or code excerpt, command output, or named test in the result. A part counts as done only if such evidence is present; a confident or @@ -73,6 +90,12 @@ pub fn is_supervisor_injection(content: &str) -> bool { const LEDGER_CAP: usize = 128; /// Args locate the object of an action (path, command, url) — not replay it. const LEDGER_ARGS_MAX: usize = 120; +/// Cap on distinct mutated paths tracked for ground truth (a task touching more +/// files than this gets diff coverage for the first N; the ledger still lists all). +const MUTATED_PATHS_CAP: usize = 16; +/// Tail of the last command's output kept for ground truth — the tail is where +/// test/build summaries land. +const LAST_COMMAND_TAIL: usize = 2_000; /// One executed tool call (or a run of identical consecutive successful calls). #[derive(Debug)] @@ -94,6 +117,12 @@ struct LedgerEntry { pub struct EvidenceLedger { entries: VecDeque, dropped: usize, + /// Paths touched by successful mutation calls this task — the ground-truth + /// diff is scoped to these. + mutated_paths: Vec, + /// Command + output tail of the last successful shell call this task — the + /// decisive check is normally the last command run before claiming done. + last_command: Option<(String, String)>, } impl EvidenceLedger { @@ -101,6 +130,31 @@ impl EvidenceLedger { pub fn reset(&mut self) { self.entries.clear(); self.dropped = 0; + self.mutated_paths.clear(); + self.last_command = None; + } + + /// Record the output of a successful shell call; only the latest is kept. + pub fn record_command_output(&mut self, command: &str, output: &str) { + let tail: String = if output.chars().count() > LAST_COMMAND_TAIL { + let skip = output.chars().count() - LAST_COMMAND_TAIL; + format!("…{}", output.chars().skip(skip).collect::()) + } else { + output.to_string() + }; + self.last_command = Some((command.to_string(), tail)); + } + + /// Paths touched by successful mutations this task (insertion order). + pub fn mutated_paths(&self) -> &[String] { + &self.mutated_paths + } + + /// Command + output tail of the last successful shell call, if any. + pub fn last_command(&self) -> Option<(&str, &str)> { + self.last_command + .as_ref() + .map(|(c, o)| (c.as_str(), o.as_str())) } /// Record one executed tool call. Only an identical consecutive repeat of a @@ -115,6 +169,20 @@ impl EvidenceLedger { error: bool, bytes: usize, ) { + // Track which files successful mutations touched, so ground truth can + // diff exactly those. Keyed on the conventional path parameter names. + if mutation && !error { + for key in ["path", "file_path"] { + if let Some(p) = parameters.get(key).and_then(|v| v.as_str()) { + if self.mutated_paths.len() < MUTATED_PATHS_CAP + && !p.trim().is_empty() + && !self.mutated_paths.iter().any(|e| e == p) + { + self.mutated_paths.push(p.to_string()); + } + } + } + } let mut args = parameters.to_string(); if args.chars().count() > LEDGER_ARGS_MAX { args = args.chars().take(LEDGER_ARGS_MAX).collect(); @@ -171,6 +239,143 @@ impl EvidenceLedger { } } +/// Render the SESSION CONTEXT block for the verifier: the durable goal (anchor +/// intent) and the live plan checklist. This is what lets the gate verify a +/// terse follow-up turn ("continue") against the real goal instead of the +/// fragment. Empty when neither exists — short single-task sessions hand the +/// gate nothing extra. +pub fn render_session_context(intent: &str, plan: Option<&str>) -> String { + let intent = intent.trim(); + let plan = plan.map(str::trim).filter(|p| !p.is_empty()); + if intent.is_empty() && plan.is_none() { + return String::new(); + } + let mut s = String::new(); + if !intent.is_empty() { + s.push_str("Session goal: "); + s.push_str(intent); + s.push('\n'); + } + if let Some(p) = plan { + s.push_str(p); + s.push('\n'); + } + s +} + +/// Cap on the git diff inside the ground-truth block. +const GT_DIFF_MAX: usize = 10_000; +/// Overall cap on the ground-truth block. +const GT_TOTAL_MAX: usize = 14_000; +/// Head of a new/untracked mutated file attached when the diff can't cover it. +const GT_FILE_HEAD_LINES: usize = 80; + +/// Runtime-gathered GROUND TRUTH for the verifier: the working-tree diff of the +/// files successful mutations touched (vs HEAD, when inside a git repo), the +/// current head of mutated files the diff does not cover (new/untracked), a +/// MISSING note for mutated files that no longer exist, and the last command's +/// recorded output tail. Deterministic — the agent's narrative cannot alter it. +/// Empty when nothing was mutated and no command ran. +pub fn render_ground_truth(mutated_paths: &[String], last_command: Option<(&str, &str)>) -> String { + let mut s = String::new(); + if !mutated_paths.is_empty() { + let diff = git_diff(mutated_paths); + if !diff.is_empty() { + s.push_str("Working-tree diff of files changed this task (vs HEAD):\n"); + s.push_str(&diff); + if !diff.ends_with('\n') { + s.push('\n'); + } + } + for p in mutated_paths { + if s.len() > GT_TOTAL_MAX { + break; + } + if diff.contains(p.as_str()) { + continue; + } + if !std::path::Path::new(p).exists() { + s.push_str(&format!( + "MISSING: {p} — mutated this task but does not exist now (deleted or never written)\n" + )); + } else if let Ok(content) = std::fs::read_to_string(p) { + s.push_str(&format!( + "Current content of {p} (new or untracked — not in diff; first {GT_FILE_HEAD_LINES} lines):\n" + )); + for line in content.lines().take(GT_FILE_HEAD_LINES) { + s.push_str(line); + s.push('\n'); + } + } + // Unreadable-as-text (binary) files are skipped: existence is already + // proven and content would not help a text verifier. + } + } + if let Some((cmd, out)) = last_command { + s.push_str("Last command run (runtime-recorded output tail):\n$ "); + s.push_str(cmd); + s.push('\n'); + s.push_str(out); + s.push('\n'); + } + if s.len() > GT_TOTAL_MAX { + let mut end = GT_TOTAL_MAX; + while !s.is_char_boundary(end) { + end -= 1; + } + s.truncate(end); + s.push_str("\n(ground truth truncated)\n"); + } + s +} + +/// `git diff HEAD -- ` in the current directory, capped. Empty on any +/// failure (not a repo, no git, no HEAD yet) — ground truth is additive +/// evidence, so absence degrades to the file-head path, never blocks. +fn git_diff(paths: &[String]) -> String { + let out = std::process::Command::new("git") + .args(["diff", "HEAD", "--"]) + .args(paths) + .output(); + match out { + Ok(o) if o.status.success() => { + let mut d = String::from_utf8_lossy(&o.stdout).into_owned(); + if d.len() > GT_DIFF_MAX { + let mut end = GT_DIFF_MAX; + while !d.is_char_boundary(end) { + end -= 1; + } + d.truncate(end); + d.push_str("\n(diff truncated)\n"); + } + d + } + _ => String::new(), + } +} + +/// Marker embedded in the plan pre-gate advisory so re-runs within the same +/// turn don't nudge twice (mirrors the mutation pre-gate marker). +pub const PLAN_GATE_MARKER: &str = "octomind:pre_gate_open_plan"; + +/// Advisory injected when `done` is self-reported while the live plan still +/// has open items — the drift-by-omission failure: parts of the decomposed +/// task silently dropped. Free and deterministic; shares the gate budget. +pub fn format_plan_advisory(open: &[String]) -> String { + let mut s = format!( + "\n\nYou reported done, but your plan still has open items:\n" + ); + for t in open { + s.push_str("- "); + s.push_str(t); + s.push('\n'); + } + s.push_str( + "The task is not done while its plan is open. For each item: do the work and mark it complete (plan `next`), or — if it is already covered or no longer applies — close it out via the plan tool (`next` with a one-line reason, or `done`/`reset` for the whole plan if it is obsolete). Then re-report your status.\n", + ); + s +} + /// Compact byte-size hint for a tool result (`412b`, `2.3k`). fn fmt_size(bytes: usize) -> String { if bytes >= 1024 { @@ -180,34 +385,69 @@ fn fmt_size(bytes: usize) -> String { } } -/// Verify a self-reported completion. `task` is the user's request, `result` is -/// the agent's final answer, `claim` is the agent's own stated reason from its -/// `done` self-report (checked against the result), `actions` is the rendered -/// [`EvidenceLedger`] (empty when no tools ran — pure-reasoning tasks). Fails -/// open (PASS) on empty input or LLM error — a verifier outage must never block -/// the agent. +/// Everything the verify-gate judges a completion claim against. All fields +/// but `task`/`result` are optional context — empty means absent. +pub struct GateInput<'a> { + /// The user's request (latest genuine user turn). + pub task: &'a str, + /// The agent's final answer. + pub result: &'a str, + /// The agent's own stated reason from its `done` self-report. + pub claim: Option<&'a str>, + /// Rendered [`EvidenceLedger`] (empty when no tools ran — pure reasoning). + pub actions: &'a str, + /// Rendered [`render_session_context`] block (durable goal + live plan). + pub context: &'a str, + /// Rendered [`render_ground_truth`] block (diff + last command output). + pub ground_truth: &'a str, + /// Gaps the previous verification pass found this task, so the re-verify + /// confirms each is closed instead of judging from scratch. + pub prior_gaps: &'a [String], +} + +/// Verify a self-reported completion against [`GateInput`]. Fails open (PASS) +/// on empty input or LLM error — a verifier outage must never block the agent. pub async fn verify( config: &Config, - task: &str, - result: &str, - claim: Option<&str>, - actions: &str, + input: GateInput<'_>, operation_rx: watch::Receiver, ) -> GateVerdict { - if task.trim().is_empty() || result.trim().is_empty() { + if input.task.trim().is_empty() || input.result.trim().is_empty() { return GateVerdict::Pass; } - let claim_line = match claim { + let claim_line = match input.claim { Some(c) if !c.trim().is_empty() => format!("\n\nAGENT'S STATED CLAIM: {c}"), _ => String::new(), }; - let actions_block = if actions.trim().is_empty() { + let actions_block = if input.actions.trim().is_empty() { + String::new() + } else { + format!("\n\nRECORDED ACTIONS:\n{}", input.actions) + }; + let context_block = if input.context.trim().is_empty() { String::new() } else { - format!("\n\nRECORDED ACTIONS:\n{actions}") + format!("\n\nSESSION CONTEXT:\n{}", input.context) }; + let ground_truth_block = if input.ground_truth.trim().is_empty() { + String::new() + } else { + format!("\n\nGROUND TRUTH:\n{}", input.ground_truth) + }; + let prior_gaps_block = if input.prior_gaps.is_empty() { + String::new() + } else { + let mut b = String::from("\n\nPREVIOUSLY FLAGGED GAPS:\n"); + for g in input.prior_gaps { + b.push_str("- "); + b.push_str(g); + b.push('\n'); + } + b + }; + let (task, result) = (input.task, input.result); let user = format!( - "USER REQUEST:\n{task}\n\nAGENT FINAL RESULT:\n{result}{claim_line}{actions_block}" + "USER REQUEST:\n{task}{context_block}\n\nAGENT FINAL RESULT:\n{result}{claim_line}{actions_block}{ground_truth_block}{prior_gaps_block}" ); // Verify with a deliberately separate (ideally different-family) model — a // same-family verifier shares the generator's blind spots and rubber-stamps @@ -362,6 +602,37 @@ mod tests { assert!(l.render().contains('…')); } + #[test] + fn session_context_empty_when_no_goal_or_plan() { + assert_eq!(render_session_context("", None), ""); + assert_eq!(render_session_context(" ", Some(" ")), ""); + } + + #[test] + fn session_context_renders_goal_and_plan() { + let c = render_session_context( + "Ship the feature", + Some("Live plan (1/2 done):\n✅ a\n🔄 b ← current"), + ); + assert!(c.starts_with("Session goal: Ship the feature\n")); + assert!(c.contains("🔄 b ← current")); + // Each part also renders alone. + assert_eq!( + render_session_context("Ship it", None), + "Session goal: Ship it\n" + ); + assert_eq!(render_session_context("", Some("plan")), "plan\n"); + } + + #[test] + fn plan_advisory_lists_items_and_carries_marker() { + let a = format_plan_advisory(&["wire it up".into(), "add tests".into()]); + assert!(is_supervisor_injection(&a)); + assert!(a.contains(PLAN_GATE_MARKER)); + assert!(a.contains("- wire it up\n")); + assert!(a.contains("- add tests\n")); + } + #[test] fn empty_ledger_renders_empty() { let mut l = EvidenceLedger::default(); @@ -370,4 +641,75 @@ mod tests { l.reset(); assert_eq!(l.render(), ""); } + + #[test] + fn ledger_tracks_mutated_paths_and_last_command() { + let mut l = EvidenceLedger::default(); + l.record( + "text_editor", + &serde_json::json!({"path":"src/a.rs"}), + true, + false, + 1, + ); + // Duplicate path and failed mutation don't add entries. + l.record( + "text_editor", + &serde_json::json!({"path":"src/a.rs"}), + true, + false, + 1, + ); + l.record( + "write", + &serde_json::json!({"file_path":"src/b.rs"}), + true, + true, + 1, + ); + // Reads never add paths. + l.record( + "view", + &serde_json::json!({"path":"src/c.rs"}), + false, + false, + 1, + ); + assert_eq!(l.mutated_paths(), &["src/a.rs".to_string()][..]); + + l.record_command_output("cargo test", "ok. 12 passed"); + assert_eq!(l.last_command(), Some(("cargo test", "ok. 12 passed"))); + l.record_command_output("cargo clippy", "clean"); + assert_eq!(l.last_command(), Some(("cargo clippy", "clean"))); + + l.reset(); + assert!(l.mutated_paths().is_empty()); + assert!(l.last_command().is_none()); + } + + #[test] + fn command_output_keeps_tail() { + let mut l = EvidenceLedger::default(); + let long = format!("{}FAILED at the end", "x".repeat(3000)); + l.record_command_output("cargo test", &long); + let (_, out) = l.last_command().expect("recorded"); + assert!(out.starts_with('…')); + assert!(out.ends_with("FAILED at the end")); + assert!(out.chars().count() <= 2_001); // tail + ellipsis + } + + #[test] + fn ground_truth_empty_when_nothing_recorded() { + assert_eq!(render_ground_truth(&[], None), ""); + } + + #[test] + fn ground_truth_reports_missing_file_and_command() { + let gt = render_ground_truth( + &["definitely/not/a/real/file.xyz".to_string()], + Some(("cargo test", "12 passed")), + ); + assert!(gt.contains("MISSING: definitely/not/a/real/file.xyz")); + assert!(gt.contains("$ cargo test\n12 passed")); + } } diff --git a/src/supervisor/mod.rs b/src/supervisor/mod.rs index a6efeafd..aa58b299 100644 --- a/src/supervisor/mod.rs +++ b/src/supervisor/mod.rs @@ -78,7 +78,8 @@ pub struct SupervisorConfig { pub model: String, /// Evidence-bound claims: instruct the agent to back load-bearing repo facts /// with a verbatim `« »` quote, then deterministically verify each quote - /// occurs in a tool result. Fabricated citations are re-grounded via the + /// occurs in a tool result — and that each `file:line` reference in the + /// final answer holds on disk. Fabricated citations are re-grounded via the /// verify-gate's bounded re-run (so this is effective only when `gate.enabled`). pub claim_check: bool, /// Cross-session learning mechanic (distill + recall). @@ -196,4 +197,9 @@ pub struct GateConfig { /// change. Tool-agnostic — keyed on the mutation-without-clean-check pattern, /// not on tool names. Runs before the LLM verify-gate (zero model calls). pub require_check_after_mutation: bool, + /// Free deterministic pre-gate: refuse a self-reported `done` while the live + /// plan checklist still has open items — the drift-by-omission failure. The + /// agent must finish them or close them out via the plan tool first. Runs + /// before the LLM verify-gate (zero model calls). + pub require_plan_complete: bool, } diff --git a/src/supervisor/recite.rs b/src/supervisor/recite.rs index 07d1004e..e6c953da 100644 --- a/src/supervisor/recite.rs +++ b/src/supervisor/recite.rs @@ -27,8 +27,9 @@ use crate::session::anchor::Anchor; /// Build the recitation note for the context tail, or `None` when there is /// nothing durable to recite yet. /// -/// Recites `intent` verbatim — it is immutable (first-write-wins) so it never -/// drifts. For the "what to do now" part it prefers the LIVE plan checklist +/// Recites `intent` verbatim — it only moves when a compaction carries a +/// sanctioned user pivot, so it never drifts through paraphrase. For the +/// "what to do now" part it prefers the LIVE plan checklist /// (`plan_checklist`, re-read every turn from the plan tool's storage) over the /// `next_steps` snapshot, which only refreshes at compaction and is stale /// between. With an active plan it recites even before the first compaction — diff --git a/src/supervisor/stats.rs b/src/supervisor/stats.rs index c7faa79c..aae45425 100644 --- a/src/supervisor/stats.rs +++ b/src/supervisor/stats.rs @@ -62,6 +62,7 @@ struct Stats { steer_sequential: u64, pregate_blocks: u64, claim_blocks: u64, + plan_blocks: u64, lessons_stored: u64, orientation_stored: u64, recalls_injected: u64, @@ -132,6 +133,10 @@ pub fn pregate_block() { pub fn claim_block() { with(|s| s.claim_blocks += 1); } +/// The plan pre-gate refused a `done` (live plan still has open items). +pub fn plan_block() { + with(|s| s.plan_blocks += 1); +} /// `n` lessons were stored by distill. pub fn lessons(n: u64) { with(|s| s.lessons_stored += n); @@ -162,6 +167,7 @@ pub fn snapshot() -> Option { && s.steers == 0 && s.pregate_blocks == 0 && s.claim_blocks == 0 + && s.plan_blocks == 0 && s.lessons_stored == 0 && s.orientation_stored == 0 && s.recalls_injected == 0; @@ -199,6 +205,7 @@ pub fn snapshot() -> Option { "steer_signals": steer_signals, "pregate_blocks": s.pregate_blocks, "claim_blocks": s.claim_blocks, + "plan_blocks": s.plan_blocks, "lessons_stored": s.lessons_stored, "orientation_stored": s.orientation_stored, "recalls_injected": s.recalls_injected,