add agent-facing compact tool gated on context usage

This commit is contained in:
damocles 2026-07-23 10:08:34 +02:00 committed by mara
commit e9df5def02
6 changed files with 133 additions and 16 deletions

View file

@ -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

View file

@ -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.

View file

@ -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,

View file

@ -491,8 +491,9 @@ async fn serve_main<S: Surface>(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");
}
});

View file

@ -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<PathBuf> {
@ -72,6 +84,7 @@ pub async fn run(
store: Arc<Todos>,
wake: Arc<Notify>,
reminders: Option<Arc<Reminders>>,
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::<Request>(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 {

View file

@ -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 `&[]`