diff --git a/doc/integration/02-acp-protocol.md b/doc/integration/02-acp-protocol.md index 7208235c..7184b2e8 100644 --- a/doc/integration/02-acp-protocol.md +++ b/doc/integration/02-acp-protocol.md @@ -194,7 +194,7 @@ workdir = "." When the AI calls `agent_context_gatherer(task="...")`, Octomind acts as the **ACP client**: 1. Spawns `octomind acp context_gatherer` as a subprocess. -2. Sends `initialize` (with `protocolVersion: "0.1.0"`) — it does **not** call `authenticate`. +2. Sends `initialize` (with `protocolVersion: 1`) — it does **not** call `authenticate`. 3. Sends `session/new` with an empty `mcpServers` list. 4. Sends `session/prompt` carrying the task as a single text block. 5. Accumulates every `agent_message_chunk` text into the result, and forwards intermediate `session/update` events (thinking, tool calls, tool results) up to the parent's notification sink so the user sees the sub-agent's progress live. diff --git a/src/mcp/agent/functions.rs b/src/mcp/agent/functions.rs index c886e388..1c7a70c9 100644 --- a/src/mcp/agent/functions.rs +++ b/src/mcp/agent/functions.rs @@ -725,10 +725,7 @@ pub async fn run_acp_command( "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": { - "protocolVersion": "0.1.0", - "clientInfo": {"name": "octomind-agent-tool", "version": "1.0"} - } + "params": acp_initialize_params() })) .as_bytes(), ) @@ -736,14 +733,13 @@ pub async fn run_acp_command( wait_for_response(&mut lines, 1).await?; // 2. session/new - let cwd_str = workdir.to_string_lossy(); stdin .write_all( msg_line(json!({ "jsonrpc": "2.0", "id": 2, "method": "session/new", - "params": {"cwd": cwd_str, "mcpServers": []} + "params": acp_new_session_params(workdir) })) .as_bytes(), ) @@ -764,10 +760,7 @@ pub async fn run_acp_command( "jsonrpc": "2.0", "id": 3, "method": "session/prompt", - "params": { - "sessionId": session_id, - "prompt": [{"type": "text", "text": task}] - } + "params": acp_prompt_params(&session_id, task) })) .as_bytes(), ) @@ -1050,6 +1043,27 @@ fn truncate_action(s: &str, max: usize) -> String { } } +/// Outgoing ACP request params, kept as functions so tests can validate them +/// against the `agent-client-protocol` schema types the octomind ACP server +/// deserializes with. +fn acp_initialize_params() -> Value { + json!({ + "protocolVersion": 1, + "clientInfo": {"name": "octomind-agent-tool", "version": "1.0"} + }) +} + +fn acp_new_session_params(workdir: &std::path::Path) -> Value { + json!({"cwd": workdir.to_string_lossy(), "mcpServers": []}) +} + +fn acp_prompt_params(session_id: &str, task: &str) -> Value { + json!({ + "sessionId": session_id, + "prompt": [{"type": "text", "text": task}] + }) +} + /// Read lines until we find a JSON-RPC response with the given id, return it. async fn wait_for_response( lines: &mut tokio::io::Lines>, @@ -1086,6 +1100,27 @@ mod tests { use super::*; use std::time::{Duration, Instant}; + /// The tap/agent client speaks raw JSON while the octomind ACP server + /// deserializes with the `agent-client-protocol` schema — pin every + /// outgoing params shape against those types so a crate upgrade that + /// changes the wire format fails here instead of at runtime (e.g. the + /// protocolVersion string → u16 break). + #[test] + fn outgoing_params_match_acp_schema() { + use agent_client_protocol::schema::v1::{ + InitializeRequest, NewSessionRequest, PromptRequest, + }; + + serde_json::from_value::(acp_initialize_params()) + .expect("initialize params must match ACP v1 schema"); + serde_json::from_value::(acp_new_session_params(std::path::Path::new( + "/tmp", + ))) + .expect("session/new params must match ACP v1 schema"); + serde_json::from_value::(acp_prompt_params("sess", "task")) + .expect("session/prompt params must match ACP v1 schema"); + } + /// initialize (id=1) + session/new (id=2) + one streamed message chunk. const HANDSHAKE: &str = r#" echo '{"jsonrpc":"2.0","id":1,"result":{}}' diff --git a/src/session/chat/conversation_compression/ai.rs b/src/session/chat/conversation_compression/ai.rs index fe55feb4..d5ee54fc 100644 --- a/src/session/chat/conversation_compression/ai.rs +++ b/src/session/chat/conversation_compression/ai.rs @@ -137,6 +137,16 @@ async fn call_ai_for_decision( let response = crate::session::chat_completion_with_validation(params).await?; + // Per-component spend for `/info` — recorded even when the decision ends up + // "don't compress" (the call happened) and even with ignore_cost (which only + // controls whether the spend counts toward the session total). + if let Some(usage) = &response.exchange.usage { + let stats = &mut session.session.info.compression_stats; + stats.input_tokens += usage.input_tokens; + stats.output_tokens += usage.output_tokens; + stats.cost += usage.cost.unwrap_or(0.0); + } + if !decision_config.ignore_cost { if let Some(cost) = response.exchange.usage.as_ref().and_then(|u| u.cost) { session.session.info.total_cost += cost; diff --git a/src/session/chat/response.rs b/src/session/chat/response.rs index baa10974..38771703 100644 --- a/src/session/chat/response.rs +++ b/src/session/chat/response.rs @@ -637,6 +637,20 @@ pub async fn process_response( let mut round_truncated = false; let mut round_dedup = false; let mut round_drift = false; + // Observational verification (free pre-gate): fingerprint the working + // tree around the round — a changed tree IS the mutation signal, + // whatever tool made the change. Only measured when the pre-gate + // consumes it (one git spawn per round). + let track_verification = params.config.supervisor.enabled + && params.config.supervisor.gate.enabled + && params.config.supervisor.gate.require_check_after_mutation; + let fp_before = if track_verification { + crate::supervisor::workdir::fingerprint() + } else { + None + }; + let mut round_verifier = false; + let mut round_mutation = false; for call in ¤t_tool_calls { let tr = tool_results.iter().find(|r| r.tool_id == call.tool_id); @@ -717,6 +731,26 @@ pub async fn process_response( round_truncated |= is_truncated; round_dedup |= is_dedup; round_drift |= is_drift; + if !is_error { + round_mutation |= is_mutation; + round_verifier |= crate::supervisor::detect::is_verifier_shaped( + &call.tool_name, + &call.parameters, + ); + } + } + + // Fold the round into the observational verification state: a round + // verifies only when a verifier-shaped call succeeded on a tree the + // round itself did not change. + if track_verification { + let fp_after = crate::supervisor::workdir::fingerprint(); + params.chat_session.detectors.note_round_verification( + fp_before, + fp_after, + round_verifier, + round_mutation, + ); } // One verdict per ROUND: the whole parallel batch is a single model decision, diff --git a/src/session/chat/session/api_executor.rs b/src/session/chat/session/api_executor.rs index cf3b9f42..d9de716a 100644 --- a/src/session/chat/session/api_executor.rs +++ b/src/session/chat/session/api_executor.rs @@ -185,9 +185,16 @@ pub async fn execute_api_call_and_process_response( // Prefer the live plan checklist (refreshed every turn from plan storage) // over the anchor's stale next_steps snapshot for the recency-slot block. let plan_checklist = crate::mcp::core::plan::render_plan_checklist(); + // Explicit prohibitions from the genuine request, recited verbatim — + // these are the instructions models abandon first as attention decays. + let constraints = + crate::session::latest_real_user_task_content(&chat_session.session.messages) + .map(crate::supervisor::recite::extract_constraints) + .unwrap_or_default(); if let Some(note) = crate::supervisor::recite::recite_note( &chat_session.session.info.anchor, plan_checklist.as_deref(), + &constraints, ) { chat_session.add_system_managed_user_message(¬e)?; crate::log_debug!("Supervisor goal recitation injected"); @@ -334,7 +341,9 @@ pub async fn execute_api_call_and_process_response( .any(|m| m.content.contains(PREGATE_MARKER)) }; if config.supervisor.gate.require_check_after_mutation - && chat_session.detectors.needs_verification() + && chat_session + .detectors + .needs_verification(crate::supervisor::workdir::fingerprint()) && !already_nudged { chat_session.add_system_managed_user_message(PREGATE_NOTE)?; diff --git a/src/session/chat/session/commands/display.rs b/src/session/chat/session/commands/display.rs index e4e537d7..d2a61a99 100644 --- a/src/session/chat/session/commands/display.rs +++ b/src/session/chat/session/commands/display.rs @@ -473,6 +473,8 @@ pub fn display_info(output: &CommandOutput) { "messages removed", "tokens saved", "avg ratio", + "tokens", + "cost", ]); if stats.conversation_compressions > 0 { block_row( @@ -501,6 +503,26 @@ pub fn display_info(output: &CommandOutput) { if avg_ratio > 0.0 { block_row("avg ratio", &format!("{:.1}%", avg_ratio), kw); } + // Compression model's own spend — separate model, so break it out. + if stats.input_tokens > 0 || stats.output_tokens > 0 { + block_row( + "tokens", + &format!( + "{} in {} {} out", + format_number(stats.input_tokens).bright_blue(), + dot, + format_number(stats.output_tokens).bright_green(), + ), + kw, + ); + } + if stats.cost > 0.0 { + block_row( + "cost", + &format!("${:.5}", stats.cost).bright_yellow().to_string(), + kw, + ); + } } // ── cache ────────────────────────────────────────────────────── diff --git a/src/session/chat/session/commands/info.rs b/src/session/chat/session/commands/info.rs index 579f6bce..455f4421 100644 --- a/src/session/chat/session/commands/info.rs +++ b/src/session/chat/session/commands/info.rs @@ -66,7 +66,9 @@ pub fn handle_info(session: &ChatSession, config: &Config) -> Result 0 { + let compression_stats = if info.compression_stats.total_compressions() > 0 + || info.compression_stats.input_tokens > 0 + { Some(info.compression_stats.clone()) } else { None diff --git a/src/session/chat/session/messages.rs b/src/session/chat/session/messages.rs index fb0a5c21..1bc5762b 100644 --- a/src/session/chat/session/messages.rs +++ b/src/session/chat/session/messages.rs @@ -258,7 +258,7 @@ impl ChatSession { 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. + // streaks. Verification state (verified fingerprint) is intentionally kept. self.detectors.reset_streak(); // Check if we should cache this user message (after push, so the message exists diff --git a/src/session/mod.rs b/src/session/mod.rs index 28da7bf3..53453689 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -235,6 +235,14 @@ pub struct CompressionStats { pub conversation_compressions: usize, pub total_messages_removed: usize, pub total_tokens_saved: u64, + // The compression decision model's own spend — a separate model from the + // agent, so `/info` can break it out while total_cost stays the overall sum. + #[serde(default)] + pub input_tokens: u64, + #[serde(default)] + pub output_tokens: u64, + #[serde(default)] + pub cost: f64, } impl CompressionStats { diff --git a/src/supervisor/detect.rs b/src/supervisor/detect.rs index 4212de5b..aa0ecc04 100644 --- a/src/supervisor/detect.rs +++ b/src/supervisor/detect.rs @@ -252,6 +252,32 @@ pub fn unverified_file_refs(response: &str) -> Vec { flagged } +/// Shape-based: is this call a candidate VERIFIER — something that executes a +/// command whose outcome can genuinely check a prior change? Judged from what +/// the runtime actually knows, not from the tool's name: the call must carry a +/// string `command` parameter (the execution signature — shells, runners, +/// remote executors all take one), and the tool must not belong to one of +/// octomind's own builtin control-plane servers (authoritative: resolved via +/// the same registry the dispatcher routes with — `plan` takes a `command` +/// parameter too, but the runtime knows it executes nothing). Whether the +/// round actually verified is then decided OBSERVATIONALLY in +/// [`Detectors::note_round_verification`]: a candidate that dirtied the tree +/// is a mutator, not a verifier. +pub fn is_verifier_shaped(tool: &str, parameters: &serde_json::Value) -> bool { + if !parameters.get("command").is_some_and(|v| v.is_string()) { + return false; + } + match crate::mcp::tool_map::get_tool_server_name(tool) { + Some(server) => !matches!( + server.as_str(), + "core" | "runtime" | "orchestration" | "agent" + ), + // Unregistered tool with a command param: treat as a candidate — the + // observational tree check still guards against false verification. + None => true, + } +} + /// 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 { @@ -311,11 +337,18 @@ pub struct Detectors { /// Hash of the user task the `centroid` belongs to; a change resets it so the /// working set never carries across turns (see [`Detectors::note_task`]). task_hash: Option, - /// A code change was made with no successful non-mutation check since — the - /// free pre-gate signal for a premature `done`. Trajectory state, NOT a - /// streak: it persists across turns until a clean check clears it, so - /// [`Detectors::reset_streak`] deliberately leaves it untouched. - unverified_mutation: bool, + /// Observational verification state (see `supervisor::workdir::fingerprint`): + /// the working-tree fingerprint at the last clean verification — a + /// verifier-shaped call that succeeded on an UNCHANGED tree. Seeded from the + /// first observed round's pre-fingerprint (the task-start tree). The pre-gate + /// compares the live fingerprint against this: any difference, made through + /// ANY tool, means unverified change. Trajectory state, NOT a streak: it + /// persists across turns, so [`Detectors::reset_streak`] leaves it untouched. + verified_fp: Option, + /// Fallback verification state for when fingerprints are unavailable (not a + /// git repo): a mutation-shaped success was seen with no verifier-shaped + /// success since. + dirty_shape: bool, } /// What the deterministic layer concluded for an action. @@ -386,9 +419,9 @@ fn hash2(a: &str, b: &str) -> u64 { impl Detectors { /// Fold ONE call's result into per-call state and return `(result_hash, novel)` /// for the caller to aggregate into the round. Updates only genuinely per-result - /// state: the seen-set (novelty memory across time) and the mutation-verification - /// flag. It decides NO signal — every signal is a per-ROUND verdict, because a - /// parallel batch is one model decision (see [`Detectors::record_round_signals`]). + /// state: the seen-set (novelty memory across time). It decides NO signal — + /// every signal is a per-ROUND verdict, because a parallel batch is one model + /// decision (see [`Detectors::record_round_signals`]). pub fn note_call( &mut self, tool: &str, @@ -396,16 +429,6 @@ impl Detectors { is_error: bool, is_mutation: bool, ) -> (u64, bool) { - // Mutation-verification tracking for the free pre-gate (premature `done`): - // a successful change sets the flag; a later successful non-mutation action - // (a check / build / test / read) clears it. Errors never clear it. - // Conservative by design — clearing on any clean non-mutation action - // under-fires rather than over-fires, so a false positive costs at most one - // extra turn. Tool-agnostic: we never name which tool verifies. - if !is_error { - self.unverified_mutation = is_mutation; - } - // Identity of this action's RESULT, keyed on tool+result so the same // output from differently-worded calls still reads as a repeat. let rhash = hash2(tool, result); @@ -527,9 +550,49 @@ impl Detectors { } } + /// Fold one completed tool ROUND into the observational verification state. + /// `fp_before`/`fp_after` are workdir fingerprints measured around the round + /// (`None` = unavailable, e.g. not a git repo). `verifier_ok` = some + /// successful call in the round was verifier-shaped ([`is_verifier_shaped`]); + /// `mutation_ok` = some successful call was mutation-shaped (the + /// no-fingerprint fallback signal). + /// + /// A round VERIFIES only when a verifier ran on an unchanged tree — a + /// "verifier" that also dirtied the tree (or ran in the same parallel batch + /// as an edit) checked an ambiguous state and proves nothing. + pub fn note_round_verification( + &mut self, + fp_before: Option, + fp_after: Option, + verifier_ok: bool, + mutation_ok: bool, + ) { + // First observation seeds the baseline: the task-start tree is, by + // definition, the last state the user accepted. + if self.verified_fp.is_none() { + if let Some(b) = fp_before { + self.verified_fp = Some(b); + } + } + let tree_unchanged = match (fp_before, fp_after) { + (Some(a), Some(b)) => a == b, + // No fingerprints: fall back to call shape. + _ => !mutation_ok, + }; + if verifier_ok && tree_unchanged { + if let Some(a) = fp_after { + self.verified_fp = Some(a); + } + self.dirty_shape = false; + } else if mutation_ok { + self.dirty_shape = true; + } + } + /// Reset the rolling windows (e.g. on a new user turn). - /// `unverified_mutation` is intentionally NOT reset — it is trajectory state - /// that only a successful check clears, not a per-streak counter. + /// Verification state (`verified_fp`/`dirty_shape`) is intentionally NOT + /// reset — it is trajectory state that only a clean verification clears, + /// not a per-streak counter. pub fn reset_streak(&mut self) { self.novelty_window.clear(); self.loop_window.clear(); @@ -612,10 +675,15 @@ impl Detectors { self.consecutive_drift = 0; } - /// Free pre-gate signal: code was changed and no successful check has run - /// since. See [`Detectors::unverified_mutation`]. - pub fn needs_verification(&self) -> bool { - self.unverified_mutation + /// Free pre-gate signal: the working tree differs from its state at the last + /// clean verification — something changed (through ANY tool) and nothing has + /// been run since to check it. `fp_now` is the live fingerprint measured at + /// decision time; without fingerprints the shape-based fallback answers. + pub fn needs_verification(&self, fp_now: Option) -> bool { + match (fp_now, self.verified_fp) { + (Some(now), Some(verified)) => now != verified, + _ => self.dirty_shape, + } } } @@ -1080,47 +1148,64 @@ mod tests { } #[test] - fn needs_verification_after_mutation_until_clean_check() { + fn verification_shape_fallback_without_fingerprints() { let mut d = Detectors::default(); - assert!(!d.needs_verification()); - // A successful edit → unverified. - d.record_action( - "edit", "ok", false, true, false, false, false, 9, 9, 0, 0, 0, - ); - assert!(d.needs_verification()); - // A failed check does NOT clear it. - d.record_action( - "shell", "error", true, false, false, false, false, 9, 9, 0, 0, 0, - ); - assert!(d.needs_verification()); - // A successful non-mutation check clears it. - d.record_action( + assert!(!d.needs_verification(None)); + // Mutation-shaped round, no verifier → unverified. + d.note_round_verification(None, None, false, true); + assert!(d.needs_verification(None)); + // A read-only round changes nothing — looking is not verifying. + d.note_round_verification(None, None, false, false); + assert!(d.needs_verification(None)); + // A round where the verifier ran alongside a mutation proves nothing. + d.note_round_verification(None, None, true, true); + assert!(d.needs_verification(None)); + // A clean verifier round clears it. + d.note_round_verification(None, None, true, false); + assert!(!d.needs_verification(None)); + } + + #[test] + fn verification_tracks_tree_fingerprint() { + let mut d = Detectors::default(); + // Round 1 seeds the baseline (10 = task-start tree); the round's edit + // moved the tree to 11 → unverified. + d.note_round_verification(Some(10), Some(11), false, true); + assert!(d.needs_verification(Some(11))); + // Verifier ran but the same round dirtied the tree (11→12): ambiguous + // state, proves nothing. + d.note_round_verification(Some(11), Some(12), true, true); + assert!(d.needs_verification(Some(12))); + // Clean verifier on an unchanged tree → verified at 12. + d.note_round_verification(Some(12), Some(12), true, false); + assert!(!d.needs_verification(Some(12))); + // Out-of-band change (e.g. an edit made through `shell sed -i`, which no + // name table could classify) — the fingerprint moves → unverified again. + assert!(d.needs_verification(Some(13))); + } + + #[test] + fn verifier_shape_requires_command_string_param() { + use serde_json::json; + // Command-string param → candidate (tool_map is empty in unit tests, so + // the control-plane exclusion is exercised in integration, not here). + assert!(is_verifier_shaped( "shell", - "tests pass", - false, - false, - false, - false, - false, - 9, - 9, - 0, - 0, - 0, - ); - assert!(!d.needs_verification()); + &json!({"command": "cargo test"}) + )); + assert!(!is_verifier_shaped("view", &json!({"path": "a.rs"}))); + assert!(!is_verifier_shaped("shell", &json!({"command": 42}))); + assert!(!is_verifier_shaped("shell", &json!({}))); } #[test] - fn reset_streak_keeps_unverified_mutation() { + fn reset_streak_keeps_verification_state() { let mut d = Detectors::default(); - d.record_action( - "edit", "ok", false, true, false, false, false, 9, 9, 0, 0, 0, - ); - assert!(d.needs_verification()); + d.note_round_verification(None, None, false, true); + assert!(d.needs_verification(None)); // reset_streak is for the rolling windows — trajectory state survives. d.reset_streak(); - assert!(d.needs_verification()); + assert!(d.needs_verification(None)); } #[test] diff --git a/src/supervisor/gate.rs b/src/supervisor/gate.rs index 103c49be..51baaf23 100644 --- a/src/supervisor/gate.rs +++ b/src/supervisor/gate.rs @@ -53,6 +53,13 @@ same task. Check each one first: it must now be closed with concrete evidence, o rebutted as wrong or out of scope. A previously flagged gap that is neither closed nor rebutted stays a gap. +The request may also contain PROHIBITIONS — things it explicitly forbids ("do not X", +"never Y", "without changing Z"). Treat each prohibition as a requirement in its own right: +check RECORDED ACTIONS and the GROUND TRUTH diff for evidence the forbidden thing was done +(a [mut] action on something the request said not to touch, a forbidden change visible in +the diff). A violated prohibition is a gap even when all requested work is complete — name +the prohibition and the violating action. + 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 diff --git a/src/supervisor/mod.rs b/src/supervisor/mod.rs index aa58b299..c5a9c04d 100644 --- a/src/supervisor/mod.rs +++ b/src/supervisor/mod.rs @@ -40,6 +40,7 @@ pub mod gate; pub mod learning; pub mod recite; pub mod stats; +pub mod workdir; use serde::{Deserialize, Serialize}; diff --git a/src/supervisor/recite.rs b/src/supervisor/recite.rs index e6c953da..a0e999c6 100644 --- a/src/supervisor/recite.rs +++ b/src/supervisor/recite.rs @@ -24,6 +24,53 @@ use crate::session::anchor::Anchor; +/// Cap on recited constraints — precision over recall; a wall of lines would +/// dilute the recency slot this block exists to exploit. +const CONSTRAINTS_MAX: usize = 8; +/// A genuine instruction is short; a long sentence merely *containing* a +/// negation is almost always descriptive prose, not a directive. +const CONSTRAINT_LEN_MAX: usize = 200; + +/// Deterministically extract explicit prohibitions from the user's request — +/// "do not X", "never Y", "must not Z". These are the instructions models +/// violate mid-task as prompt attention decays, so they get re-recited at the +/// context tail verbatim. Domain-agnostic: matches directive phrasing, not any +/// particular subject. High precision by construction: unit must be a short +/// non-question sentence/line containing a strong negative imperative. +pub fn extract_constraints(task: &str) -> Vec { + const MARKERS: [&str; 6] = [ + "do not ", + "don't ", + "never ", + "must not ", + "not allowed", + "forbidden", + ]; + let mut out: Vec = Vec::new(); + for line in task.lines() { + // Sentence-ish units: split lines on terminators so one long line + // containing an instruction still yields just that instruction. + for unit in line.split_inclusive(['.', '!', ';']) { + let unit = unit + .trim() + .trim_start_matches(['-', '*', '•', '>']) + .trim_start_matches(|c: char| c.is_ascii_digit() || c == '.' || c == ')') + .trim(); + if unit.is_empty() || unit.len() > CONSTRAINT_LEN_MAX || unit.ends_with('?') { + continue; + } + let lower = unit.to_lowercase(); + if MARKERS.iter().any(|m| lower.contains(m)) && !out.iter().any(|e| e == unit) { + out.push(unit.to_string()); + if out.len() >= CONSTRAINTS_MAX { + return out; + } + } + } + } + out +} + /// Build the recitation note for the context tail, or `None` when there is /// nothing durable to recite yet. /// @@ -33,15 +80,27 @@ use crate::session::anchor::Anchor; /// (`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 — -/// that is exactly when goal drift on a long task is most expensive. Wrapped in -/// `` so [`crate::supervisor::gate::is_supervisor_injection`] -/// excludes it from the verify-gate's real-task search. -pub fn recite_note(anchor: &Anchor, plan_checklist: Option<&str>) -> Option { - if anchor.intent.is_empty() && anchor.next_steps.is_empty() && plan_checklist.is_none() { +/// that is exactly when goal drift on a long task is most expensive. +/// `constraints` (see [`extract_constraints`]) recite the request's explicit +/// prohibitions verbatim — the instructions models abandon first as attention +/// decays — and fire from turn one: a constraint is most violated exactly +/// while the raw request is still in context but no longer attended to. +/// Wrapped in `` so +/// [`crate::supervisor::gate::is_supervisor_injection`] excludes it from the +/// verify-gate's real-task search. +pub fn recite_note( + anchor: &Anchor, + plan_checklist: Option<&str>, + constraints: &[String], +) -> Option { + if anchor.intent.is_empty() + && anchor.next_steps.is_empty() + && plan_checklist.is_none() + && constraints.is_empty() + { return None; } - let mut s = - String::from("\nYou are deep in this session — re-anchor on your goal:\n"); + let mut s = String::from("\nRe-anchor on your task:\n"); if !anchor.intent.is_empty() { s.push_str("Goal (fixed): "); s.push_str(anchor.intent.trim()); @@ -63,6 +122,14 @@ pub fn recite_note(anchor: &Anchor, plan_checklist: Option<&str>) -> Option"); Some(s) } @@ -74,7 +141,40 @@ mod tests { #[test] fn empty_anchor_recites_nothing() { - assert!(recite_note(&Anchor::default(), None).is_none()); + assert!(recite_note(&Anchor::default(), None, &[]).is_none()); + } + + #[test] + fn constraints_extracted_high_precision() { + let task = "System:\nYou are an engineer. Resolve the issue with the minimal change. \ +Do NOT modify tests. When done, stop.\n\nInstruction:\nThe parser crashes on empty input. \ +It never worked for unicode?\n- don't touch the CI config\n1. Never commit directly.\n\ +This long descriptive sentence merely mentions that the value must not exceed the buffer size when the flag is set and goes on and on about internals of the allocation strategy across multiple clauses to exceed the length cap entirely and then some more."; + let c = extract_constraints(task); + assert!(c.iter().any(|x| x == "Do NOT modify tests."), "{c:?}"); + assert!(c.iter().any(|x| x == "don't touch the CI config"), "{c:?}"); + assert!(c.iter().any(|x| x == "Never commit directly."), "{c:?}"); + // Questions and over-long prose are excluded. + assert!(!c.iter().any(|x| x.contains("unicode")), "{c:?}"); + assert!(!c.iter().any(|x| x.contains("buffer size")), "{c:?}"); + } + + #[test] + fn constraints_deduped_and_capped() { + let line = "Do not push.\n".repeat(20); + let c = extract_constraints(&line); + assert_eq!(c, vec!["Do not push."]); + let many: String = (0..20).map(|i| format!("Never touch file{i}.\n")).collect(); + assert_eq!(extract_constraints(&many).len(), 8); + } + + #[test] + fn constraints_recite_without_anchor_or_plan() { + let cs = vec!["Do NOT modify tests.".to_string()]; + let note = recite_note(&Anchor::default(), None, &cs).expect("constraints recite alone"); + assert!(crate::supervisor::gate::is_supervisor_injection(¬e)); + assert!(note.contains("- Do NOT modify tests.")); + assert!(note.contains("still binding")); } #[test] @@ -88,7 +188,7 @@ mod tests { }, 0, ); - let note = recite_note(&a, None).expect("should recite"); + let note = recite_note(&a, None, &[]).expect("should recite"); // Excluded from the gate's real-task search. assert!(crate::supervisor::gate::is_supervisor_injection(¬e)); assert!(note.contains("Add the truncation detector")); @@ -105,7 +205,7 @@ mod tests { }, 0, ); - let note = recite_note(&a, None).expect("should recite"); + let note = recite_note(&a, None, &[]).expect("should recite"); assert!(note.contains("Refactor auth")); assert!(!note.contains("Last-known next steps")); } @@ -124,6 +224,7 @@ mod tests { let note = recite_note( &a, Some("Live plan (1/2 done):\n✅ done it\n🔄 do this ← current"), + &[], ) .expect("should recite"); assert!(note.contains("Ship the feature")); @@ -138,6 +239,7 @@ mod tests { let note = recite_note( &Anchor::default(), Some("Live plan (0/1 done):\n🔄 first ← current"), + &[], ) .expect("active plan recites pre-compaction"); assert!(crate::supervisor::gate::is_supervisor_injection(¬e)); diff --git a/src/supervisor/workdir.rs b/src/supervisor/workdir.rs new file mode 100644 index 00000000..d2e9536c --- /dev/null +++ b/src/supervisor/workdir.rs @@ -0,0 +1,51 @@ +// Copyright 2026 Muvon Un Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Working-tree fingerprint — observational ground truth for "did anything +//! change". Classifying tools by name guesses at effects; measuring the tree +//! observes them, so a change made through ANY tool (an editor, a shell +//! `sed -i`, a sub-agent) is caught identically. + +use std::hash::{Hash, Hasher}; + +/// Hash of the working tree's dirty state: the `git status --porcelain -uall` +/// output folded with (size, mtime) of every dirty path — so re-modifying an +/// already-dirty file still moves the fingerprint even though its status line +/// is unchanged. `None` outside a git repo (or git missing/failing); callers +/// degrade to shape-based signals. Cost: one git spawn plus a stat per dirty +/// file — run at most once per tool round. +pub fn fingerprint() -> Option { + let out = std::process::Command::new("git") + .args(["status", "--porcelain", "-uall"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8_lossy(&out.stdout); + let mut h = std::collections::hash_map::DefaultHasher::new(); + text.hash(&mut h); + for line in text.lines() { + // porcelain v1: `XY `; renames render as `XY old -> new`. + let path = line.get(3..).unwrap_or(""); + let path = path.rsplit(" -> ").next().unwrap_or(path).trim_matches('"'); + if let Ok(md) = std::fs::metadata(path) { + md.len().hash(&mut h); + if let Ok(mt) = md.modified() { + mt.hash(&mut h); + } + } + } + Some(h.finish()) +} diff --git a/tests/smoke/acp.sh b/tests/smoke/acp.sh index 6cb5cfe8..3a2a4f55 100755 --- a/tests/smoke/acp.sh +++ b/tests/smoke/acp.sh @@ -56,7 +56,7 @@ get_response() { grep "\"id\":$1" "$TMPOUT" 2>/dev/null | tail -1; } # ── 1. initialize ───────────────────────────────────────────────────────────── info "Test 1: initialize" -send '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"0.1.0","clientInfo":{"name":"test","version":"0.1"}}}' +send '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientInfo":{"name":"test","version":"0.1"}}}' if wait_for_id 1 10; then R=$(get_response 1) NAME=$(echo "$R" | jq -r '.result.agentInfo.name // empty' 2>/dev/null) diff --git a/tests/smoke/schedule.sh b/tests/smoke/schedule.sh index 5883a737..81b59d6d 100755 --- a/tests/smoke/schedule.sh +++ b/tests/smoke/schedule.sh @@ -61,7 +61,7 @@ get_response() { grep "\"id\":$1" "$TMPOUT" 2>/dev/null | tail -1; } # ── 1. Initialize ──────────────────────────────────────────────────────────── info "Initializing ACP" -send '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"0.1.0","clientInfo":{"name":"schedule-test","version":"0.1"}}}' +send '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientInfo":{"name":"schedule-test","version":"0.1"}}}' if ! wait_for_id 1 10; then fail "initialize — timeout" exit 1