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
22 changed files with 1296 additions and 47 deletions
68
swarmctl/src/agents_md.rs
Normal file
68
swarmctl/src/agents_md.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
//! `swarmctl agent agents-md` — read an agent's `AGENTS.md` and edit it with
|
||||
//! a diff, through swarm-controller's `/api/agents/{name}/agents-md` routes.
|
||||
//!
|
||||
//! The read is the copy the agent's harness last published, so it works
|
||||
//! while the agent is stopped. An edit is applied by the harness to the file
|
||||
//! itself, so it needs the agent running.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use swarm_controller_api::agents_md::{AgentsMdEdit, AgentsMdSaved, AgentsMdView};
|
||||
|
||||
use crate::agent::{parse_ident, send};
|
||||
use crate::remote::Target;
|
||||
|
||||
fn runtime() -> Result<tokio::runtime::Runtime> {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.context("starting a tokio runtime for the controller request")
|
||||
}
|
||||
|
||||
/// The agent's `AGENTS.md` path. The name is checked first, so it cannot
|
||||
/// reshape the URI.
|
||||
fn member(agent: &str) -> Result<String> {
|
||||
let agent = parse_ident(agent, "agent name")?;
|
||||
Ok(format!("/api/agents/{agent}/agents-md"))
|
||||
}
|
||||
|
||||
/// Run `swarmctl agent agents-md get`: the text on stdout, unchanged, and its
|
||||
/// revision on stderr, so stdout redirected to a file is the file.
|
||||
pub(crate) fn get(target: &Target, agent: &str) -> Result<()> {
|
||||
let uri = member(agent)?;
|
||||
let view: AgentsMdView = runtime()?.block_on(send(
|
||||
target,
|
||||
hyper::Method::GET,
|
||||
&uri,
|
||||
None::<&()>,
|
||||
"agents-md-get",
|
||||
))?;
|
||||
eprintln!("revision {}", view.revision);
|
||||
print!("{}", view.content);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run `swarmctl agent agents-md apply`: send the diff in `diff_file`, made
|
||||
/// against `base`, and print the file's new revision.
|
||||
pub(crate) fn apply(target: &Target, agent: &str, base: &str, diff_file: &Path) -> Result<()> {
|
||||
let uri = member(agent)?;
|
||||
let diff = std::fs::read_to_string(diff_file)
|
||||
.with_context(|| format!("reading the diff {}", diff_file.display()))?;
|
||||
let edit = AgentsMdEdit {
|
||||
base_revision: base.to_owned(),
|
||||
diff,
|
||||
};
|
||||
let saved: AgentsMdSaved = runtime()?.block_on(send(
|
||||
target,
|
||||
hyper::Method::PUT,
|
||||
&uri,
|
||||
Some(&edit),
|
||||
"agents-md-apply",
|
||||
))?;
|
||||
println!(
|
||||
"applied; {agent}'s AGENTS.md is at revision {}",
|
||||
saved.revision
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
//! for the same reason `hivectl` does not link `hive-c0re`.
|
||||
|
||||
mod agent;
|
||||
mod agents_md;
|
||||
mod forge;
|
||||
mod inference;
|
||||
mod login;
|
||||
|
|
@ -302,6 +303,43 @@ enum AgentVerb {
|
|||
#[command(subcommand)]
|
||||
command: PermissionsVerb,
|
||||
},
|
||||
/// Show an agent's AGENTS.md, or edit it with a diff.
|
||||
///
|
||||
/// `get` shows the copy the agent last published, so it works while the
|
||||
/// agent is stopped. `apply` hands the diff to the agent, which applies
|
||||
/// it to the file with `git apply`; it needs the agent running.
|
||||
AgentsMd {
|
||||
#[command(subcommand)]
|
||||
command: AgentsMdVerb,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum AgentsMdVerb {
|
||||
/// Print an agent's AGENTS.md on stdout, and its revision on stderr.
|
||||
Get(InferenceAgentArgs),
|
||||
/// Apply a unified diff to an agent's AGENTS.md.
|
||||
///
|
||||
/// The diff names `a/AGENTS.md` and `b/AGENTS.md`, as
|
||||
/// `diff -u a/AGENTS.md b/AGENTS.md` writes it. The agent refuses it when
|
||||
/// the file is no longer at `--base`, or when it doesn't apply, and
|
||||
/// leaves the file as it was.
|
||||
Apply(AgentsMdApplyArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct AgentsMdApplyArgs {
|
||||
/// Agent name.
|
||||
agent: String,
|
||||
/// The file holding the diff.
|
||||
diff_file: PathBuf,
|
||||
/// The revision the diff was made against, as `get` printed it. It is
|
||||
/// the git blob id of that text, so `git hash-object` of the unedited
|
||||
/// file prints it too.
|
||||
#[arg(long, value_name = "REVISION")]
|
||||
base: String,
|
||||
#[command(flatten)]
|
||||
socket: ControllerSocketArg,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
|
|
@ -645,6 +683,9 @@ fn main() -> Result<()> {
|
|||
Verb::Agent {
|
||||
command: AgentVerb::Permissions { command },
|
||||
} => permissions(&remote, command),
|
||||
Verb::Agent {
|
||||
command: AgentVerb::AgentsMd { command },
|
||||
} => agents_md(&remote, command),
|
||||
// Same socket-resolution reasoning as `Create` above.
|
||||
Verb::Forge {
|
||||
command: ForgeVerb::MakeAdmin(args),
|
||||
|
|
@ -724,6 +765,15 @@ fn permissions(remote: &RemoteArgs, command: PermissionsVerb) -> Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
fn agents_md(remote: &RemoteArgs, command: AgentsMdVerb) -> Result<()> {
|
||||
match command {
|
||||
AgentsMdVerb::Get(a) => agents_md::get(&a.socket.resolve(remote)?, &a.agent),
|
||||
AgentsMdVerb::Apply(a) => {
|
||||
agents_md::apply(&a.socket.resolve(remote)?, &a.agent, &a.base, &a.diff_file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule(remote: &RemoteArgs, command: ScheduleVerb) -> Result<()> {
|
||||
match command {
|
||||
ScheduleVerb::Add(a) => schedule::add(
|
||||
|
|
|
|||
Loading…
Reference in a new issue