ag3nt: manager MCP tools + prompt for scheduled prompts (#444)

This commit is contained in:
damocles 2026-05-26 01:31:39 +02:00
commit 2593896383
3 changed files with 138 additions and 1 deletions

View file

@ -11,6 +11,9 @@ Tools (hyperhive surface):
- `mcp__hyperhive__restart(name)` — stop + start a sub-agent. No approval required.
- `mcp__hyperhive__update(name)` — rebuild a sub-agent (re-applies the current hyperhive flake + agent.nix, restarts the container). No approval required — idempotent. Use when you receive a `needs_update` system event.
- `mcp__hyperhive__request_update_meta_inputs(inputs?, description?)` — queue an approval for the operator to run `nix flake update [inputs...]` on the meta flake. Pass specific input names (e.g. `["bitburner-agent"]`) or omit / pass `[]` for all inputs. Returns immediately; lock update runs on operator approval. Does NOT trigger rebuilds — call `update(name)` on affected agents after approval resolves.
- `mcp__hyperhive__request_schedule_prompt(targets, body, first_fire_at_unix, interval_seconds?, description?)` — queue an approval for the operator to add a scheduled prompt. On approve hive-c0re inserts a schedule row and the worker fans `body` out to each agent in `targets` at `first_fire_at_unix` (recurring every `interval_seconds` if set, one-shot when absent). Even self-targeted schedules go through approval — the existing `remind` tool stays the quick no-approval self-wake path. Catch-up clamp: long downtime fires ONCE per recurring row on resume (skipped count surfaces in per-target `last_result`), not N stacked pulses.
- `mcp__hyperhive__cancel_schedule(id, targets?)` — cancel a schedule. Omit `targets` / pass empty to cancel the whole schedule; pass a list to cancel just those recipients (the schedule keeps firing for any remaining active targets, auto-cancels when every target is gone). Authorization: you can cancel schedules you own OR any owned by a sub-agent in your subtree per topology.json.
- `mcp__hyperhive__list_schedules()` — snapshot every schedule in the queue (active + cancelled-but-not-reaped). Returns id, owner, body, target set with per-target `last_fired_at` + `last_result`, `next_fire_at_unix`, recurring `interval_seconds`. Use to look up an id before cancelling, or to audit upcoming wake-ups across the swarm.
- `mcp__hyperhive__get_logs(agent, lines?)` — fetch recent journal lines for a sub-agent container. Use to diagnose MCP-server registration failures, startup crashes, or harness issues you can't see from inside. Pass the plain logical agent name; `lines` defaults to 50 (capped at 500).
- `mcp__hyperhive__ask(question, options?, multi?, ttl_seconds?, to?)` — surface a structured question to the operator (default, or `to: "operator"`) OR a sub-agent (`to: "<agent-name>"`). Returns immediately with a question id; the answer arrives later as a system `question_answered { id, question, answer, answerer }` event in your inbox. Options are advisory: the dashboard always lets the operator type a free-text answer in addition. Set `multi: true` to render options as checkboxes (operator can pick multiple); the answer comes back as `, `-separated. Set `ttl_seconds` to auto-cancel after a deadline (capped at 6h server-side) — on expiry the answer is `[expired]` and `answerer` is `"ttl-watchdog"`. Do not poll inside the same turn — finish the current work and react when the event lands.
- `mcp__hyperhive__answer(id, answer)` — answer a question that was routed to YOU (a sub-agent did `ask(to: "manager", ...)`). The triggering event in your inbox is `question_asked { id, asker, question, options, multi }`. The answer surfaces in the asker's inbox as a `question_answered` event.

View file

@ -179,7 +179,8 @@ async fn serve(
| ManagerResponse::LooseEnds { .. }
| ManagerResponse::PendingRemindersCount { .. }
| ManagerResponse::ReminderRollup { .. }
| ManagerResponse::AgentMeta { .. },
| ManagerResponse::AgentMeta { .. }
| ManagerResponse::Schedules { .. },
) => {
tracing::warn!("recv produced unexpected response kind");
}

View file

@ -46,6 +46,9 @@ pub enum SocketReply {
QuestionQueued(i64),
Recent(Vec<hive_sh4re::InboxRow>),
Logs(String),
/// `list_schedules` result — used by the manager surface only;
/// AgentResponse has no equivalent variant.
Schedules(Vec<hive_sh4re::WireSchedule>),
LooseEnds(Vec<hive_sh4re::LooseEnd>),
PendingRemindersCount(u64),
ReminderRollup(hive_sh4re::ReminderStats),
@ -102,6 +105,7 @@ impl From<hive_sh4re::ManagerResponse> for SocketReply {
hive_sh4re::ManagerResponse::QuestionQueued { id } => Self::QuestionQueued(id),
hive_sh4re::ManagerResponse::Recent { rows } => Self::Recent(rows),
hive_sh4re::ManagerResponse::Logs { content } => Self::Logs(content),
hive_sh4re::ManagerResponse::Schedules { schedules } => Self::Schedules(schedules),
hive_sh4re::ManagerResponse::LooseEnds { loose_ends } => Self::LooseEnds(loose_ends),
hive_sh4re::ManagerResponse::PendingRemindersCount { count } => {
Self::PendingRemindersCount(count)
@ -929,6 +933,46 @@ pub struct UpdateMetaInputsArgs {
pub description: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct RequestSchedulePromptArgs {
/// Recipient agents — one schedule fires to many inboxes at the
/// scheduled time. `operator` is a legitimate target (mara: "we
/// want to get rid of the manager special case so yes manager
/// can be recipient" — the operator slot follows the same rule).
pub targets: Vec<String>,
/// Message body delivered to each target's inbox at fire time.
/// Same size budget as `send` bodies.
pub body: String,
/// Absolute unix timestamp (seconds) for the FIRST fire. For
/// recurring schedules the worker re-arms in
/// `interval_seconds` steps from this point on.
pub first_fire_at_unix: i64,
/// `None` / absent = one-shot. `Some(n > 0)` = recurring every
/// `n` seconds. The worker clamps catch-up so a long downtime
/// fires ONCE on resume (skipped-cycle count surfaces in the
/// per-target last_result), not N delayed pulses in a row.
#[serde(default)]
pub interval_seconds: Option<u64>,
/// Optional description shown on the dashboard approval card +
/// preserved on the schedule row for later operator reference.
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct CancelScheduleArgs {
/// Schedule id from a prior `list_schedules` call or the
/// approval-resolved event for a `request_schedule_prompt`.
pub id: i64,
/// Optional target list. `None` / empty = cancel the entire
/// schedule. `Some(["alice", "bob"])` = cancel just those
/// recipients (the schedule keeps firing for any remaining
/// active targets, and auto-cancels its parent row when every
/// target is gone).
#[serde(default)]
pub targets: Option<Vec<String>>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct GetLogsArgs {
/// Logical agent name to fetch logs for (e.g. `gui`, `hm1nd`).
@ -1181,6 +1225,95 @@ impl ManagerServer {
.await
}
#[tool(
description = "Queue an approval to add a scheduled prompt — one body delivered to \
N agent inboxes at a target time, optionally recurring every `interval_seconds`. \
The operator approves; on approve hive-c0re inserts the schedule and the worker \
fans it out. Even self-targeted schedules go through this flow (the operator pays \
for the wake-up tokens); the existing `remind` MCP tool stays the quick \
no-approval self-wake path. \n\n\
Catch-up clamp: if hive-c0re is down across multiple intervals, only ONE delayed \
fire happens on resume (per recurring schedule). The skipped-cycle count surfaces \
in the per-target `last_result` for the operator's audit trail. \n\n\
Per-target failure: a target name that doesn't resolve to a live agent operator \
gets a one-line advisory `Message` from `system`; the schedule keeps firing for \
the other (live) targets."
)]
async fn request_schedule_prompt(
&self,
Parameters(args): Parameters<RequestSchedulePromptArgs>,
) -> String {
let log = format!("{args:?}");
run_tool_envelope("request_schedule_prompt", log, async move {
let target_count = args.targets.len();
let (resp, retries) = self
.dispatch(hive_sh4re::ManagerRequest::RequestSchedulePrompt(
hive_sh4re::SchedulePromptPayload {
targets: args.targets,
body: args.body,
first_fire_at_unix: args.first_fire_at_unix,
interval_seconds: args.interval_seconds,
description: args.description,
},
))
.await;
annotate_retries(
format_ack(
resp,
"request_schedule_prompt",
format!("approval queued: {target_count} target(s)"),
),
retries,
)
})
.await
}
#[tool(
description = "Cancel a scheduled prompt. With no `targets` field, cancels the \
whole schedule (all recipients flipped). With a non-empty `targets` list, cancels \
just those recipients; the schedule keeps firing for any remaining active targets \
and auto-cancels its parent row when every target is cancelled. \n\n\
Authorization: the manager can cancel its own schedules + any schedule whose \
owner is one of its sub-agents per topology.json. Other owners are refused."
)]
async fn cancel_schedule(&self, Parameters(args): Parameters<CancelScheduleArgs>) -> String {
let log = format!("{args:?}");
run_tool_envelope("cancel_schedule", log, async move {
let id = args.id;
let (resp, retries) = self
.dispatch(hive_sh4re::ManagerRequest::CancelSchedule {
id: args.id,
targets: args.targets,
})
.await;
annotate_retries(format_ack(resp, "cancel_schedule", format!("cancelled #{id}")), retries)
})
.await
}
#[tool(
description = "List every scheduled prompt in the queue (active + cancelled but \
not yet reaped). Returns the full snapshot schedule id, owner, body, target set \
with per-target last_fired_at + last_result, next fire time, recurring interval. \
Use this to look up an id before calling `cancel_schedule`, or to audit what \
the swarm is going to be woken up about next."
)]
async fn list_schedules(&self) -> String {
run_tool_envelope("list_schedules", String::new(), async move {
let (resp, retries) = self.dispatch(hive_sh4re::ManagerRequest::ListSchedules).await;
let body = match resp {
Ok(SocketReply::Schedules(schedules)) => serde_json::to_string(&schedules)
.unwrap_or_else(|e| format!("list_schedules: serialise: {e:#}")),
Ok(SocketReply::Err(m)) => format!("list_schedules: {m}"),
Ok(other) => format!("list_schedules unexpected response: {other:?}"),
Err(e) => format!("list_schedules transport error: {e:#}"),
};
annotate_retries(body, retries)
})
.await
}
#[tool(
description = "Surface a structured question to either the operator OR a sub-agent. \
Returns immediately with a question id do NOT wait inline. When the recipient \