Watch
0
0
Fork
You've already forked hyperhive
0

read an agent's agents.md through the swarm and edit it with git diffs

The agent's state/AGENTS.md stays the truth and its harness the only
writer. The harness mirrors the file into a new `agents-md` KV bucket,
and swarm-controller serves that copy, so a read works while the agent is
stopped. An edit is a unified diff plus the revision it was made against,
sent over the agent's existing control subject; the harness refuses a
stale base, applies the diff with `git apply --check` then `git apply` to
a temp copy, and writes the file only when both pass. Fixes #5519.

Files:
- swarm-queue-client/src/agents_md.rs, lib.rs: the bucket name and
  subject, the mirror value, the `ApplyAgentsMd` control command and its
  reply. `AgentControl` loses `Copy` because the new variant carries text.
- hive-agent/src/swarm_agents_md.rs, main.rs: the mirror task (start,
  10 s re-read, immediate republish after an edit) and the apply.
- hive-agent/src/swarm_input.rs: the control loop answers both commands.
- hive-agent/src/claude_md_watch.rs: its AGENTS.md path is shared.
- hive-agent/Cargo.toml, Cargo.lock: sha1 for the git blob id (already in
  the lock), tempfile moves from dev to runtime for the apply directory.
- swarm-controller/src/agents_md.rs, main.rs: the bucket reader and
  GET/PUT /api/agents/{name}/agents-md; test AppState literals get the
  new field.
- swarm-controller/src/agent_control.rs: the sink hands back raw reply
  bytes, so each command decodes its own reply type.
- swarm-controller-api/src/agents_md.rs: the route bodies, shared with
  swarmctl.
- swarmctl/src/agents_md.rs, main.rs: `swarmctl agent agents-md get|apply`.
- nix/host-modules/swarm-nats.nix, nix/module-eval/nats-authelia.nix: the
  agent's publish grant on its own `$KV.agents-md.<agent>` key.

git needs no new package: agent containers enable programs.git, and the
harness unit's PATH includes /run/current-system/sw.
This commit is contained in:
atlas 2026-10-18 03:11:15 +02:00 • committed by mara
commit 88674eb63a
22 changed files with 1296 additions and 47 deletions

View file

@ -27,8 +27,8 @@ use crate::{AppState, error_problem};
/// What became of one command.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Outcome {
/// The agent answered.
Answered(AgentControlReply),
/// The agent answered with this payload, a reply to the command sent.
Answered(Vec<u8>),
/// Nothing subscribes to the agent's control subject.
NotRunning,
/// A subscriber held the command and did not answer in time.
@ -50,10 +50,7 @@ impl ControlSink for async_nats::Client {
.payload(payload.into())
.timeout(Some(swarm_queue_client::AGENT_CONTROL_REPLY_DEADLINE));
match self.send_request(subject, request).await {
Ok(reply) => match serde_json::from_slice(&reply.payload) {
Ok(reply) => Outcome::Answered(reply),
Err(e) => Outcome::Failed(format!("the agent's answer was unreadable: {e}")),
},
Ok(reply) => Outcome::Answered(reply.payload.to_vec()),
Err(e) => match e.kind() {
RequestErrorKind::TimedOut => Outcome::Unconfirmed,
RequestErrorKind::NoResponders => Outcome::NotRunning,
@ -117,7 +114,7 @@ pub(crate) async fn post_agent_cancel(
/// The agent and who is asking, or the refusal. Everything decided before the
/// queue is touched.
fn target(
pub(crate) fn target(
headers: &HeaderMap,
agent: &str,
) -> Result<(String, String), (StatusCode, &'static str)> {
@ -143,6 +140,12 @@ async fn send(
})?;
match sink.request(subject.clone(), payload).await {
Outcome::Answered(reply) => {
let reply: AgentControlReply = serde_json::from_slice(&reply).map_err(|e| {
error_problem(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("{agent}'s answer was unreadable: {e}"),
)
})?;
tracing::info!(%agent, %from, ?command, acted = reply.acted, "agent command answered");
Ok(Json(CancelResponse { acted: reply.acted }))
}
@ -231,10 +234,14 @@ mod tests {
async fn the_answer_is_relayed_from_the_agent_s_own_control_subject() {
let answers: [(bool, fn() -> Outcome); 2] = [
(true, || {
Outcome::Answered(AgentControlReply { acted: true })
Outcome::Answered(
serde_json::to_vec(&AgentControlReply { acted: true }).expect("encodes"),
)
}),
(false, || {
Outcome::Answered(AgentControlReply { acted: false })
Outcome::Answered(
serde_json::to_vec(&AgentControlReply { acted: false }).expect("encodes"),
)
}),
];
for (acted, answer) in answers {

View file

@ -493,6 +493,7 @@ pub(crate) mod tests {
wanted: Some(Arc::new(WantedWriter::new(client.clone()))),
agent_status: None,
agent_icons: None,
agents_md: None,
reported_inference: None,
agent_login: None,
jobq: Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(

View file

@ -0,0 +1,436 @@
//! `GET` and `PUT /api/agents/{name}/agents-md`: an agent's `AGENTS.md`.
//!
//! **The file in the agent's state dir is the truth, and only its harness
//! writes it.** A read serves the copy the harness publishes into the
//! `agents-md` bucket at start and on every change
//! ([`swarm_queue_client::agents_md`]), so it answers for a stopped agent
//! too. An edit is a git diff sent to the harness as a command on its
//! control subject ([`crate::agent_control`]); the harness applies it to the
//! file and says whether it did, so an edit needs the agent running.
//!
//! No hive is on either path.
use axum::Json;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse as _, Response};
use problem_details::ProblemDetails;
use swarm_controller_api::agents_md::{AgentsMdEdit, AgentsMdSaved, AgentsMdView};
use swarm_queue_client::AgentControl;
use swarm_queue_client::agents_md::{Apply, ApplyReply, Mirror};
use crate::agent_control::{ControlSink, Outcome};
use crate::{AppState, error_problem, wanted_error_status};
/// Reads the `agents-md` bucket, the way [`crate::agent_icon::AgentIconReader`]
/// reads the icon bucket, and creates it: the agents writing it cannot.
pub struct AgentsMdReader {
client: async_nats::Client,
store: tokio::sync::OnceCell<async_nats::jetstream::kv::Store>,
}
impl AgentsMdReader {
#[must_use]
pub fn new(client: async_nats::Client) -> Self {
Self {
client,
store: tokio::sync::OnceCell::new(),
}
}
async fn store(
&self,
) -> std::result::Result<&async_nats::jetstream::kv::Store, swarm_queue_client::Error> {
self.store
.get_or_try_init(|| swarm_queue_client::agents_md::open_or_create(&self.client))
.await
}
/// Open the bucket, creating it, as soon as the queue is connected. Until
/// then every agent's write is refused and retried.
pub async fn create_when_connected(&self) {
const POLL: std::time::Duration = std::time::Duration::from_secs(5);
loop {
if swarm_queue_client::ensure_connected(&self.client).is_ok() {
match self.store().await {
Ok(_) => return,
Err(e) => tracing::warn!(
error = swarm_queue_client::chain(&e),
"creating the agents-md bucket failed; retrying"
),
}
}
tokio::time::sleep(POLL).await;
}
}
/// `agent`'s published `AGENTS.md`, or `None` when it has published none.
async fn get(&self, agent: &str) -> anyhow::Result<Option<Mirror>> {
use anyhow::Context as _;
// An unconnected client hangs on a JetStream request rather than
// failing it.
swarm_queue_client::ensure_connected(&self.client)?;
let store = self.store().await?;
let Some(bytes) = store
.get(agent)
.await
.with_context(|| format!("reading the agents-md entry for {agent}"))?
else {
return Ok(None);
};
let mirror = serde_json::from_slice(&bytes)
.with_context(|| format!("the agents-md entry for {agent} is not a mirror"))?;
Ok(Some(mirror))
}
}
/// Read an agent's `AGENTS.md` as its harness last published it.
#[utoipa::path(
get,
path = "/api/agents/{name}/agents-md",
params(("name" = String, Path, description = "agent name")),
responses(
(status = 200, description = "the file's text and revision, as the agent last \
published them; answered while the agent is stopped too", body = AgentsMdView),
(status = 400, description = "not an identifier (problem+json)", body = String),
(status = 404, description = "this agent has published no AGENTS.md: its harness \
has not run since it started mirroring the file (problem+json)", body = String),
(status = 500, description = "the bucket could not be read (problem+json)", body = String),
(status = 503, description = "no swarm queue is wired up, or it is not connected \
(problem+json)", body = String),
),
tag = "agents"
)]
pub(crate) async fn get_agents_md(
State(state): State<AppState>,
Path(name): Path<String>,
) -> Result<Json<AgentsMdView>, ProblemDetails> {
let Some(reader) = state.agents_md.as_ref() else {
return Err(error_problem(
StatusCode::SERVICE_UNAVAILABLE,
"no swarm queue is wired up on this host",
));
};
let agent = hive_types::Ident::parse(&name)
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
.into_string();
let mirror = reader.get(&agent).await.map_err(|e| {
tracing::warn!(%agent, error = %format!("{e:#}"), "reading the agents-md entry failed");
error_problem(wanted_error_status(&e), &format!("{e:#}"))
})?;
let Some(Mirror { content, revision }) = mirror else {
return Err(error_problem(
StatusCode::NOT_FOUND,
"this agent has published no AGENTS.md",
));
};
Ok(Json(AgentsMdView { content, revision }))
}
/// The revision a 409 names, as a problem+json extension member.
#[derive(serde::Serialize)]
struct CurrentRevision {
revision: String,
}
/// Edit an agent's `AGENTS.md` with a git diff, which the agent's harness
/// applies to the file.
#[utoipa::path(
put,
path = "/api/agents/{name}/agents-md",
params(("name" = String, Path, description = "agent whose AGENTS.md to edit")),
request_body = AgentsMdEdit,
responses(
(status = 200, description = "the diff is applied; the body is the file's new \
revision", body = AgentsMdSaved),
(status = 400, description = "not an identifier (problem+json)", body = String),
(status = 401, description = "the request carries no `Remote-User` (problem+json)",
body = String),
(status = 409, description = "the file is no longer at `base_revision`, so nothing \
was applied; `revision` in the problem+json body is the one it is at", body = String),
(status = 422, description = "`git apply` refused the diff, or it touches something \
other than AGENTS.md; `detail` is git's message and the file is unchanged \
(problem+json)", body = String),
(status = 503, description = "the agent is not running, or its harness is not \
connected to the swarm queue, or no swarm queue is configured on this host or it \
could not be reached (problem+json)", body = String),
(status = 504, description = "the harness held the edit and did not answer in time, \
so whether it was applied is unknown (problem+json)", body = String),
(status = 500, description = "the queue refused or failed the request, or the harness \
could not read or write the file (problem+json)", body = String),
),
tag = "agents"
)]
pub(crate) async fn put_agents_md(
State(state): State<AppState>,
Path(agent): Path<String>,
headers: HeaderMap,
Json(edit): Json<AgentsMdEdit>,
) -> Result<Json<AgentsMdSaved>, Response> {
let problem = |s: StatusCode, d: &str| error_problem(s, d).into_response();
let (agent, from) =
crate::agent_control::target(&headers, &agent).map_err(|(s, d)| problem(s, d))?;
let Some(status) = state.status.as_ref() else {
return Err(problem(
StatusCode::SERVICE_UNAVAILABLE,
"no swarm queue is configured on this host",
));
};
let client = status.queue_client();
swarm_queue_client::ensure_connected(&client).map_err(|e| {
problem(
StatusCode::SERVICE_UNAVAILABLE,
&swarm_queue_client::chain(&e),
)
})?;
send(&client, &agent, &from, edit).await
}
async fn send(
sink: &impl ControlSink,
agent: &str,
from: &str,
edit: AgentsMdEdit,
) -> Result<Json<AgentsMdSaved>, Response> {
let problem = |s: StatusCode, d: &str| error_problem(s, d).into_response();
let subject = swarm_queue_client::agent_control_subject(agent);
let command = AgentControl::ApplyAgentsMd(Apply {
base_revision: edit.base_revision,
diff: edit.diff,
});
let payload = serde_json::to_vec(&command).map_err(|e| {
problem(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("encoding the edit failed: {e}"),
)
})?;
let reply = match sink.request(subject.clone(), payload).await {
Outcome::Answered(reply) => serde_json::from_slice::<ApplyReply>(&reply).map_err(|e| {
problem(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("{agent}'s answer was unreadable: {e}"),
)
})?,
Outcome::NotRunning => {
return Err(problem(
StatusCode::SERVICE_UNAVAILABLE,
&format!(
"{agent} is not running: its harness is not listening for commands, so \
AGENTS.md cannot be edited until it is"
),
));
}
Outcome::Unconfirmed => {
tracing::warn!(%agent, %from, "agents-md edit unanswered");
return Err(problem(
StatusCode::GATEWAY_TIMEOUT,
&format!("{agent} did not answer in time: whether the edit was applied is unknown"),
));
}
Outcome::Failed(e) => {
tracing::warn!(%agent, %subject, error = %e, "agents-md edit failed");
return Err(problem(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("sending to {subject} failed: {e}"),
));
}
};
match reply {
ApplyReply::Applied { revision } => {
tracing::info!(%agent, %from, %revision, "agents-md edit applied");
Ok(Json(AgentsMdSaved { revision }))
}
ApplyReply::Stale { revision } => {
Err(ProblemDetails::from_status_code(StatusCode::CONFLICT)
.with_detail(format!(
"{agent}'s AGENTS.md changed since the edit was made: it is at {revision}. \
Reload it and make the edit again"
))
.with_extensions(CurrentRevision { revision })
.into_response())
}
ApplyReply::Rejected { error } => Err(problem(
StatusCode::UNPROCESSABLE_ENTITY,
&format!("the diff does not apply to {agent}'s AGENTS.md: {error}"),
)),
ApplyReply::Failed { error } => {
tracing::warn!(%agent, %error, "agents-md edit failed in the harness");
Err(problem(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("{agent} could not apply the edit: {error}"),
))
}
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use axum::http::StatusCode;
use swarm_controller_api::agents_md::AgentsMdEdit;
use swarm_queue_client::AgentControl;
use swarm_queue_client::agents_md::{Apply, ApplyReply};
use super::send;
use crate::agent_control::{ControlSink, Outcome};
/// Answers every command with one fixed outcome and records what it got.
struct FakeSink {
answer: Box<dyn Fn() -> Outcome + Send + Sync>,
got: Mutex<Vec<(String, Vec<u8>)>>,
}
impl FakeSink {
fn replying(reply: &ApplyReply) -> Self {
let bytes = serde_json::to_vec(reply).expect("encodes");
Self::new(move || Outcome::Answered(bytes.clone()))
}
fn new(answer: impl Fn() -> Outcome + Send + Sync + 'static) -> Self {
Self {
answer: Box::new(answer),
got: Mutex::default(),
}
}
}
impl ControlSink for FakeSink {
async fn request(&self, subject: String, payload: Vec<u8>) -> Outcome {
self.got
.lock()
.expect("unpoisoned")
.push((subject, payload));
(self.answer)()
}
}
fn edit() -> AgentsMdEdit {
AgentsMdEdit {
base_revision: "abc".to_owned(),
diff: "--- a/AGENTS.md\n".to_owned(),
}
}
async fn body(resp: axum::response::Response) -> serde_json::Value {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("body");
serde_json::from_slice(&bytes).expect("json")
}
#[tokio::test]
async fn an_edit_goes_to_the_agent_s_control_subject_and_its_revision_comes_back() {
let sink = FakeSink::replying(&ApplyReply::Applied {
revision: "def".to_owned(),
});
let saved = send(&sink, "atlas", "mara", edit()).await.expect("applied");
assert_eq!(saved.0.revision, "def");
let got = sink.got.lock().expect("unpoisoned");
assert_eq!(got[0].0, "$SWARM.control.atlas");
let sent: AgentControl = serde_json::from_slice(&got[0].1).expect("a command");
assert_eq!(
sent,
AgentControl::ApplyAgentsMd(Apply {
base_revision: "abc".to_owned(),
diff: "--- a/AGENTS.md\n".to_owned(),
})
);
}
#[tokio::test]
async fn a_stale_base_is_409_naming_the_current_revision() {
let sink = FakeSink::replying(&ApplyReply::Stale {
revision: "fff".to_owned(),
});
let resp = send(&sink, "atlas", "mara", edit())
.await
.expect_err("stale");
assert_eq!(resp.status(), StatusCode::CONFLICT);
assert_eq!(body(resp).await["revision"], "fff");
}
#[tokio::test]
async fn a_diff_git_refuses_is_422_with_git_s_message() {
let sink = FakeSink::replying(&ApplyReply::Rejected {
error: "error: patch failed: AGENTS.md:1".to_owned(),
});
let resp = send(&sink, "atlas", "mara", edit())
.await
.expect_err("rejected");
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
let detail = body(resp).await["detail"]
.as_str()
.expect("detail")
.to_owned();
assert!(detail.contains("patch failed: AGENTS.md:1"), "{detail}");
}
#[tokio::test]
async fn no_subscriber_is_503_and_the_rest_are_504_and_500() {
let status = |sink: FakeSink| async move {
send(&sink, "atlas", "mara", edit())
.await
.expect_err("not a success")
.status()
};
assert_eq!(
status(FakeSink::new(|| Outcome::NotRunning)).await,
StatusCode::SERVICE_UNAVAILABLE
);
assert_eq!(
status(FakeSink::new(|| Outcome::Unconfirmed)).await,
StatusCode::GATEWAY_TIMEOUT
);
assert_eq!(
status(FakeSink::new(|| Outcome::Failed("refused".to_owned()))).await,
StatusCode::INTERNAL_SERVER_ERROR
);
assert_eq!(
status(FakeSink::replying(&ApplyReply::Failed {
error: "disk full".to_owned()
}))
.await,
StatusCode::INTERNAL_SERVER_ERROR
);
// A cancel's answer is not an edit's.
assert_eq!(
status(FakeSink::new(|| Outcome::Answered(
br#"{"acted":true}"#.to_vec()
)))
.await,
StatusCode::INTERNAL_SERVER_ERROR
);
}
/// Through a real `nats-server`: nothing is served before the harness
/// publishes, and its publish, straight to the key's subject as the
/// harness sends it, is what a read returns.
#[tokio::test]
async fn a_read_serves_what_the_harness_published() {
use swarm_queue_client::agents_md::{Mirror, subject};
let nats = crate::test_nats::TestNats::start();
let client = nats.client().await;
let reader = super::AgentsMdReader::new(client.clone());
reader.create_when_connected().await;
assert_eq!(reader.get("atlas").await.expect("read"), None);
let mirror = Mirror {
content: "# atlas\n".to_owned(),
revision: "abc".to_owned(),
};
let js = async_nats::jetstream::new(client.clone());
js.publish(
subject("atlas"),
serde_json::to_vec(&mirror).expect("encodes").into(),
)
.await
.expect("sent")
.await
.expect("acked");
assert_eq!(reader.get("atlas").await.expect("read"), Some(mirror));
assert_eq!(reader.get("iris").await.expect("read"), None);
}
}

View file

@ -153,6 +153,7 @@ mod tests {
wanted: None,
agent_status: None,
agent_icons: None,
agents_md: None,
reported_inference: None,
agent_login: None,
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(

View file

@ -123,6 +123,7 @@ mod tests {
wanted: None,
agent_status: None,
agent_icons: None,
agents_md: None,
reported_inference: None,
agent_login: None,
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(

View file

@ -962,6 +962,7 @@ pub(crate) mod tests {
wanted: None,
agent_status: None,
agent_icons: None,
agents_md: None,
reported_inference: None,
agent_login: None,
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(

View file

@ -55,6 +55,7 @@ mod agent_permissions;
mod agent_renewal;
mod agent_state_stream;
mod agent_status;
mod agents_md;
mod alerts;
mod archetypes;
mod auth;
@ -954,6 +955,9 @@ struct AppState {
/// connection, several consumers" rationale as `agent_status` above,
/// and `None` in exactly the state `status` is.
agent_icons: Option<Arc<agent_icon::AgentIconReader>>,
/// Each agent's published `AGENTS.md`, on `status`'s connection like
/// `agent_icons`, and `None` in exactly the state `status` is.
agents_md: Option<Arc<agents_md::AgentsMdReader>>,
/// What each agent last reported running on, from its turn-state
/// headers on `status`'s connection. `None` in exactly the state
/// `status` is.
@ -1325,6 +1329,17 @@ fn agent_icon_reader(
Some(reader)
}
/// The `AGENTS.md` reader, sharing the status reader's connection, and
/// creating the bucket, as [`agent_icon_reader`] does for icons.
fn agents_md_reader(
status: Option<&Arc<status::StatusReader>>,
) -> Option<Arc<agents_md::AgentsMdReader>> {
let reader = Arc::new(agents_md::AgentsMdReader::new(status?.queue_client()));
let creating = Arc::clone(&reader);
tokio::spawn(async move { creating.create_when_connected().await });
Some(reader)
}
/// What each agent last reported running on, read on the status reader's
/// connection — same rationale as [`wanted_writer`].
fn reported_inference(
@ -3612,6 +3627,7 @@ async fn main() -> Result<()> {
wanted: wanted_writer(status.as_ref()),
agent_status: agent_status_reader(status.as_ref()),
agent_icons: agent_icon_reader(status.as_ref()),
agents_md: agents_md_reader(status.as_ref()),
reported_inference: reported_inference(status.as_ref()),
agent_login: agent_login(status.as_ref()),
runs: review_classifier::runs::Runs::Live(status.as_ref().map(|s| s.queue_client())),
@ -3630,8 +3646,7 @@ async fn main() -> Result<()> {
alerts: alerts::from_env(),
};
let app = build_app(state);
axum::serve(listener, app)
axum::serve(listener, build_app(state))
.await
.context("serving swarm-controller")
}
@ -3654,6 +3669,7 @@ fn build_app(state: AppState) -> axum::Router {
.routes(routes!(get_jobq_rollup))
.routes(routes!(get_agent_config_pr))
.routes(routes!(get_agent_icon))
.routes(routes!(agents_md::get_agents_md, agents_md::put_agents_md))
.routes(routes!(
inference::get_agent_inference,
inference::put_agent_inference,
@ -3833,6 +3849,7 @@ mod tests {
wanted: None,
agent_status: None,
agent_icons: None,
agents_md: None,
reported_inference: None,
agent_login: None,
jobq: std::sync::Arc::clone(&sched),

View file

@ -457,6 +457,7 @@ mod tests {
wanted: None,
agent_status: None,
agent_icons: None,
agents_md: None,
reported_inference: None,
agent_login: None,
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(