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:
parent
f4f459f0b2
commit
88674eb63a
1 changed files with 1296 additions and 47 deletions
|
|
@ -21,10 +21,12 @@
|
|||
//! request that times out as "a subscriber held it".
|
||||
//!
|
||||
//! **Commands are a separate subject and are answered.** The swarm UI's
|
||||
//! cancel arrives on [`swarm_queue_client::agent_control_subject`], never on
|
||||
//! the input subject, so nothing a person types can be read as a command. The
|
||||
//! reply says whether a turn was actually stopped; the agent's grant allows
|
||||
//! answering only the request it was just sent (`allow_responses`).
|
||||
//! cancel and `AGENTS.md` edits arrive on
|
||||
//! [`swarm_queue_client::agent_control_subject`], never on the input subject,
|
||||
//! so nothing a person types can be read as a command. A cancel's reply says
|
||||
//! whether a turn was actually stopped, an edit's whether it was applied
|
||||
//! ([`crate::swarm_agents_md`]); the agent's grant allows answering only the
|
||||
//! request it was just sent (`allow_responses`).
|
||||
|
||||
use futures_util::{Stream, StreamExt as _};
|
||||
use hive_sh4re::inbox::DeliveredMessage;
|
||||
|
|
@ -70,35 +72,45 @@ async fn run_control(
|
|||
tracing::warn!("swarm control: command without a reply subject dropped");
|
||||
continue;
|
||||
};
|
||||
let Some(answer) = act(&msg.payload, |command| match command {
|
||||
swarm_queue_client::AgentControl::Cancel => {
|
||||
crate::web_ui::cancel_turn(&bus, &interrupted, "swarm UI: cancel")
|
||||
}
|
||||
}) else {
|
||||
let Some(answer) = act(
|
||||
&msg.payload,
|
||||
|| crate::web_ui::cancel_turn(&bus, &interrupted, "swarm UI: cancel"),
|
||||
crate::swarm_agents_md::handle,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let payload = serde_json::to_vec(&answer).expect("a reply encodes");
|
||||
if let Err(e) = client.publish(reply, payload.into()).await {
|
||||
if let Err(e) = client.publish(reply, answer.into()).await {
|
||||
tracing::warn!(error = %e, "swarm control: answering failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The answer to a command payload, running `cancel` for a cancel; `None`
|
||||
/// (logged) when the payload is not a command, which gets no answer.
|
||||
fn act(
|
||||
/// The encoded answer to a command payload: `cancel` runs for a cancel and
|
||||
/// `apply` for an `AGENTS.md` edit. `None` (logged) when the payload is not a
|
||||
/// command, which gets no answer.
|
||||
async fn act<F>(
|
||||
payload: &[u8],
|
||||
cancel: impl FnOnce(swarm_queue_client::AgentControl) -> bool,
|
||||
) -> Option<swarm_queue_client::AgentControlReply> {
|
||||
match serde_json::from_slice::<swarm_queue_client::AgentControl>(payload) {
|
||||
Ok(command) => Some(swarm_queue_client::AgentControlReply {
|
||||
acted: cancel(command),
|
||||
}),
|
||||
cancel: impl FnOnce() -> bool,
|
||||
apply: impl FnOnce(swarm_queue_client::agents_md::Apply) -> F,
|
||||
) -> Option<Vec<u8>>
|
||||
where
|
||||
F: Future<Output = swarm_queue_client::agents_md::ApplyReply>,
|
||||
{
|
||||
use swarm_queue_client::AgentControl;
|
||||
|
||||
let encoded = match serde_json::from_slice::<AgentControl>(payload) {
|
||||
Ok(AgentControl::Cancel) => {
|
||||
serde_json::to_vec(&swarm_queue_client::AgentControlReply { acted: cancel() })
|
||||
}
|
||||
Ok(AgentControl::ApplyAgentsMd(edit)) => serde_json::to_vec(&apply(edit).await),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "swarm control: undecodable command dropped");
|
||||
None
|
||||
return None;
|
||||
}
|
||||
}
|
||||
};
|
||||
Some(encoded.expect("a reply encodes"))
|
||||
}
|
||||
|
||||
async fn run(tx: UnboundedSender<DeliveredMessage>, agent: String) {
|
||||
|
|
@ -156,6 +168,7 @@ fn delivered(payload: &[u8]) -> Option<DeliveredMessage> {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{act, forward};
|
||||
use swarm_queue_client::agents_md::{Apply, ApplyReply};
|
||||
use swarm_queue_client::{AgentControl, AgentControlReply, AgentInput};
|
||||
|
||||
fn payload(from: &str, body: &str) -> Vec<u8> {
|
||||
|
|
@ -204,20 +217,58 @@ mod tests {
|
|||
forward(payloads, &tx).await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancel_is_answered_with_whether_it_acted() {
|
||||
#[tokio::test]
|
||||
async fn a_cancel_is_answered_with_whether_it_acted() {
|
||||
for acted in [true, false] {
|
||||
let got = act(br#""cancel""#, |c| {
|
||||
assert_eq!(c, AgentControl::Cancel);
|
||||
acted
|
||||
});
|
||||
assert_eq!(got, Some(AgentControlReply { acted }));
|
||||
let got = act(
|
||||
br#""cancel""#,
|
||||
|| acted,
|
||||
|_| async { panic!("applied an edit for a cancel") },
|
||||
)
|
||||
.await
|
||||
.expect("answered");
|
||||
let got: AgentControlReply = serde_json::from_slice(&got).expect("a cancel reply");
|
||||
assert_eq!(got, AgentControlReply { acted });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_payload_that_is_not_a_command_is_not_answered_or_acted_on() {
|
||||
let got = act(b"pause", |_| panic!("ran a command that was not one"));
|
||||
#[tokio::test]
|
||||
async fn an_edit_is_answered_with_the_apply_s_reply() {
|
||||
let edit = Apply {
|
||||
base_revision: "abc".to_owned(),
|
||||
diff: "a diff".to_owned(),
|
||||
};
|
||||
let payload =
|
||||
serde_json::to_vec(&AgentControl::ApplyAgentsMd(edit.clone())).expect("encodes");
|
||||
let got = act(
|
||||
&payload,
|
||||
|| panic!("cancelled for an edit"),
|
||||
|got| async move {
|
||||
assert_eq!(got, edit);
|
||||
ApplyReply::Applied {
|
||||
revision: "def".to_owned(),
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("answered");
|
||||
let got: ApplyReply = serde_json::from_slice(&got).expect("an apply reply");
|
||||
assert_eq!(
|
||||
got,
|
||||
ApplyReply::Applied {
|
||||
revision: "def".to_owned()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_payload_that_is_not_a_command_is_not_answered_or_acted_on() {
|
||||
let got = act(
|
||||
b"pause",
|
||||
|| panic!("ran a command that was not one"),
|
||||
|_| async { panic!("ran a command that was not one") },
|
||||
)
|
||||
.await;
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue