diff --git a/docs/turn-loop/mcp.md b/docs/turn-loop/mcp.md index 4ad46ed0..de57c8cc 100644 --- a/docs/turn-loop/mcp.md +++ b/docs/turn-loop/mcp.md @@ -110,6 +110,19 @@ at_unix_timestamp?)`, `request_next_turn()`. `homeserver`); omitted for agents with no matrix provisioning. Omit `name` to query self. +**Always-on, no tool group** (like `set_status`): `compact()`. + +- `compact` — agent self-service equivalent of the operator dashboard's + `/compact` button. No args. Gated server-side on the last completed + turn's context usage: refused (with an explanation, no side effect) + unless usage is above 66% of the effective context window. On a + pass, queues the same deferred `compact_pending` flag the dashboard + button sets — consumed at the end of the current turn, so it never + races a live claude process, and the usual pre-compaction + notes-checkpoint turn still fires first. Dispatched through the + in-agent socket (`hive-agent-sock::Request::Compact`), not the + broker — see `hive-agent/src/todo_server.rs`. + ## Privileged tools (by tool group) - **Bash execution** (`execution`) — background shell tasks. See diff --git a/hive-agent-mcp/src/mcp/mod.rs b/hive-agent-mcp/src/mcp/mod.rs index 177379c7..d3a14b91 100644 --- a/hive-agent-mcp/src/mcp/mod.rs +++ b/hive-agent-mcp/src/mcp/mod.rs @@ -587,6 +587,29 @@ impl AgentServer { .await } + #[tool( + description = "Compact the current session's context, mirroring the operator's \ + dashboard `/compact` button. Gated: only honoured when this agent's last \ + completed turn used more than 66% of the effective context window — below \ + that the call is refused with an explanation and has no effect. On a pass, \ + queues compaction for the end of the current turn (same deferred mechanism \ + the dashboard button uses, so it never races a live claude process); the \ + usual pre-compaction notes-checkpoint turn still fires first. No args." + )] + async fn compact(&self) -> String { + run_tool_envelope("compact", String::new(), async move { + match dial_agent_socket(&hive_agent_sock::Request::Compact).await { + Some(hive_agent_sock::Response::Ok) => { + "compact queued — will run at the end of the current turn".to_owned() + } + Some(hive_agent_sock::Response::Err { message }) => message, + Some(other) => format!("compact: unexpected response: {other:?}"), + None => "compact failed: in-agent socket unavailable".to_owned(), + } + }) + .await + } + // IMPORTANT: this tool is only available when the `lifecycle` tool group // is granted to this agent. hive-c0re enforces the topology check // server-side: the call is rejected unless `name` is a direct child. diff --git a/hive-agent-sock/src/lib.rs b/hive-agent-sock/src/lib.rs index a94a5908..a3ff9a45 100644 --- a/hive-agent-sock/src/lib.rs +++ b/hive-agent-sock/src/lib.rs @@ -83,6 +83,14 @@ pub enum Request { /// reminder-activity chart data (was `hive-core-agent-sock`'s /// `ReminderRollup` against the broker; now served from the local store). ReminderRollup { since_secs: u64 }, + /// Agent self-service request to compact the current session, mirroring + /// the operator's `/compact` dashboard button. Gated server-side: only + /// honoured when the last completed turn's context usage is above 66% + /// of the effective context window — below that, `Response::Err` + /// explains why and takes no action. On a pass, queues the same + /// deferred `compact_pending` flag the operator's button sets (consumed + /// at the next turn boundary), so it never races a live claude process. + Compact, } /// A response on the in-agent socket. Serialised with a `kind` tag, diff --git a/hive-agent/src/main.rs b/hive-agent/src/main.rs index d4b546a3..a5249d33 100644 --- a/hive-agent/src/main.rs +++ b/hive-agent/src/main.rs @@ -491,8 +491,9 @@ async fn serve_main(socket: &Path, poll_ms: u64) -> Result<()> { let store = Arc::new(store); let wake = todo_wake.clone(); let reminders = reminder_store.clone(); + let bus_for_socket = bus.clone(); tokio::spawn(async move { - if let Err(e) = todo_server::run(store, wake, reminders).await { + if let Err(e) = todo_server::run(store, wake, reminders, bus_for_socket).await { tracing::error!(error = %e, "in-agent todo socket exited with error"); } }); diff --git a/hive-agent/src/todo_server.rs b/hive-agent/src/todo_server.rs index ace90cb5..3bc280a1 100644 --- a/hive-agent/src/todo_server.rs +++ b/hive-agent/src/todo_server.rs @@ -1,14 +1,18 @@ -//! In-agent socket server (loose-ends v2 + harness-local reminders). Binds -//! the harness-owned `HIVE_AGENT_SOCKET` and serves the `hive-agent-sock` -//! protocol to the in-container producers (matrix / bash daemons, -//! forge-notify) and to `hive-agent-mcp`'s `remind`/`get_loose_ends`/ -//! `cancel_loose_end` tool impls. Todo ops hit the harness-local [`Todos`] -//! store; a new-or-changed upsert fires an in-process [`Notify`] so the -//! serve loop drives a turn. Reminder ops hit the harness-local -//! [`Reminders`] store (`None` when the store failed to open — every -//! reminder op then returns `Response::Err`); a reminder *firing* is a -//! separate path (`reminder_timer`), not driven through this socket. No -//! hive-c0re round-trip, no broker long-poll, no marker files. +//! In-agent socket server (loose-ends v2 + harness-local reminders + +//! self-service compact). Binds the harness-owned `HIVE_AGENT_SOCKET` and +//! serves the `hive-agent-sock` protocol to the in-container producers +//! (matrix / bash daemons, forge-notify) and to `hive-agent-mcp`'s +//! `remind`/`get_loose_ends`/`cancel_loose_end`/`compact` tool impls. Todo +//! ops hit the harness-local [`Todos`] store; a new-or-changed upsert fires +//! an in-process [`Notify`] so the serve loop drives a turn. Reminder ops +//! hit the harness-local [`Reminders`] store (`None` when the store failed +//! to open — every reminder op then returns `Response::Err`); a reminder +//! *firing* is a separate path (`reminder_timer`), not driven through this +//! socket. `Request::Compact` is the odd one out — it doesn't touch either +//! store, just the harness's [`Bus`] (gate-checked context usage, then the +//! same deferred `compact_pending` flag the operator dashboard's +//! `/compact` button sets). No hive-c0re round-trip, no broker long-poll, +//! no marker files. //! //! One request/response line per connection, matching the producers' //! existing best-effort JSON-line clients (they just change which socket @@ -24,9 +28,17 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; use tokio::sync::Notify; +use crate::events::Bus; use crate::reminders::{Reminder, Reminders}; use crate::todos::{Todo, Todos}; +/// Context-usage floor for `Request::Compact`: below this fraction of the +/// effective context window, an agent-initiated compact is refused (nothing +/// meaningful to reclaim yet, and it'd just burn a compaction pass on a +/// near-empty session). Same number a human operator would eyeball off the +/// dashboard's `ctx·Nk` badge before hitting the `/compact` button. +const COMPACT_MIN_USAGE_FRACTION: f64 = 0.66; + /// Resolve the in-agent socket path from `HIVE_AGENT_SOCKET`. `None` when /// unset/empty (e.g. a standalone dev run) — the server then stays off. fn socket_path() -> Option { @@ -72,6 +84,7 @@ pub async fn run( store: Arc, wake: Arc, reminders: Option>, + bus: Bus, ) -> Result<()> { let Some(path) = socket_path() else { tracing::info!("HIVE_AGENT_SOCKET unset — in-agent todo socket disabled"); @@ -85,8 +98,11 @@ pub async fn run( let store = store.clone(); let wake = wake.clone(); let reminders = reminders.clone(); + let bus = bus.clone(); tokio::spawn(async move { - if let Err(e) = handle_conn(stream, &store, &wake, reminders.as_deref()).await { + if let Err(e) = + handle_conn(stream, &store, &wake, reminders.as_deref(), &bus).await + { tracing::warn!(error = ?e, "in-agent todo connection failed"); } }); @@ -117,6 +133,7 @@ async fn handle_conn( store: &Todos, wake: &Notify, reminders: Option<&Reminders>, + bus: &Bus, ) -> Result<()> { let (read, mut write) = stream.into_split(); let mut reader = BufReader::new(read); @@ -125,7 +142,7 @@ async fn handle_conn( return Ok(()); } let resp = match serde_json::from_str::(line.trim()) { - Ok(req) => dispatch(req, store, wake, reminders), + Ok(req) => dispatch(req, store, wake, reminders, bus), Err(e) => Response::Err { message: format!("bad request: {e}"), }, @@ -140,7 +157,13 @@ async fn handle_conn( /// Apply one request to the store, firing `wake` on a new/changed upsert so /// the serve loop runs a turn. `reminders` is `None` when that store /// failed to open at boot — every reminder op then returns an `Err`. -fn dispatch(req: Request, store: &Todos, wake: &Notify, reminders: Option<&Reminders>) -> Response { +fn dispatch( + req: Request, + store: &Todos, + wake: &Notify, + reminders: Option<&Reminders>, + bus: &Bus, +) -> Response { match req { Request::UpsertTodo { subsystem, @@ -233,9 +256,52 @@ fn dispatch(req: Request, store: &Todos, wake: &Notify, reminders: Option<&Remin } None => no_reminders_store(), }, + Request::Compact => compact(bus), } } +/// `Request::Compact` handler: gate on context usage, then queue the same +/// deferred `compact_pending` flag the operator's `/compact` button sets. +/// Mirrors `hive-agent::web_ui::actions::post_compact` but reachable from +/// the agent's own MCP tool instead of the dashboard, and refuses below +/// [`COMPACT_MIN_USAGE_FRACTION`] instead of always honouring the request — +/// an agent can call this speculatively, a human clicking the dashboard +/// button already made the judgment call. +fn compact(bus: &Bus) -> Response { + let Some(usage) = bus.last_ctx_usage() else { + return Response::Err { + message: "compact refused: no completed turn yet — nothing to compact".to_owned(), + }; + }; + let model = bus.model(); + let window = bus.effective_context_window(&model); + if window == 0 { + return Response::Err { + message: "compact refused: effective context window is unknown (0)".to_owned(), + }; + } + // Token counts stay well under 2^53 in practice, so the f64 conversion + // is exact; this is a threshold ratio, not a byte-count display, but the + // same "cosmetic precision loss" reasoning as `hivectl::quota::human_bytes` applies. + #[allow(clippy::cast_precision_loss)] + let fraction = usage.context_tokens() as f64 / window as f64; + if fraction < COMPACT_MIN_USAGE_FRACTION { + return Response::Err { + message: format!( + "compact refused: context usage is {:.0}% of the {window}-token window, \ + below the {:.0}% floor — not worth compacting yet", + fraction * 100.0, + COMPACT_MIN_USAGE_FRACTION * 100.0 + ), + }; + } + bus.request_compact(); + bus.emit(crate::events::LiveEvent::Note { + text: "agent: self-requested /compact — running at the end of the current turn".into(), + }); + Response::Ok +} + /// Shared "reminders db unavailable" response for every reminder op when /// the store failed to open at boot (see `main.rs`'s best-effort open). fn no_reminders_store() -> Response { diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 56dfef38..08dc82b5 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -640,7 +640,13 @@ impl ToolGroup { /// dark on the dashboard. The server-side `SetStatus` handler has no /// tool-group check either (only length validation), so listing it here /// keeps the `--allowedTools` list honest with that reality. - pub const ALWAYS_ON_TOOLS: &'static [&'static str] = &["set_status"]; + /// + /// `compact` lives here too: it's pure self-management (no cross-agent + /// effect, no privilege), gated server-side on context usage rather + /// than on tool groups, and every agent should be able to reach for it + /// regardless of which optional groups it's been granted — same + /// reasoning as `set_status`. + pub const ALWAYS_ON_TOOLS: &'static [&'static str] = &["set_status", "compact"]; /// The Claude built-in tool names enabled by this group. Only /// `WebTools` returns a non-empty slice; all other groups return `&[]`