Watch
0
0
Fork
You've already forked hyperhive
0

set an agent's initial agents.md at create

This commit is contained in:
atlas 2026-10-18 06:39:19 +02:00 • committed by mara
commit 456cf9a7cb
19 changed files with 708 additions and 19 deletions

View file

@ -812,6 +812,7 @@ mod tests {
inference: None,
permissions,
archetype: None,
agents_md: None,
}),
)
};
@ -873,6 +874,7 @@ mod tests {
inference: None,
permissions: None,
archetype: None,
agents_md: None,
}),
)
.await

View file

@ -0,0 +1,252 @@
//! The text an agent's `AGENTS.md` starts with: one key per agent in
//! `agents-md-initial` (`swarm_queue_client::agents_md_initial`), which this
//! daemon alone writes. `POST /api/agents` stores it before it queues the
//! creation; a destroy deletes it. The agent's harness reads its own key at
//! start and writes the file only when none exists.
use problem_details::ProblemDetails;
use swarm_queue_client::agents_md_initial::{self, Initial, MAX_BYTES};
use crate::routes::{AppState, error_problem};
use crate::schedules::{queue, read_or_default, write_revision};
/// How many times a losing writer re-reads and re-applies, as in
/// `wanted::MAX_ATTEMPTS`.
const MAX_ATTEMPTS: usize = 5;
fn queue_problem(e: &anyhow::Error) -> ProblemDetails {
tracing::warn!(error = %format!("{e:#}"), "agents-md initial text: queue operation failed");
error_problem(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
&format!("{e:#}"),
)
}
/// Refuse a text longer than [`MAX_BYTES`].
pub(crate) fn check(content: &str) -> Result<(), String> {
if content.len() > MAX_BYTES {
return Err(format!(
"agents_md is {} bytes; the limit is {MAX_BYTES}",
content.len()
));
}
Ok(())
}
/// What [`store`] replaced, for [`restore`]; `None` when it wrote nothing.
pub(crate) type Replaced = Option<crate::schedules::Replaced<Initial>>;
/// Record that `agent`'s `AGENTS.md` starts with `content`.
pub(crate) async fn store(
client: &async_nats::Client,
agent: &str,
content: &str,
) -> Result<Replaced, ProblemDetails> {
let store = agents_md_initial::open_or_create(client)
.await
.map_err(|e| queue_problem(&e.into()))?;
let value = Initial {
content: content.to_owned(),
};
for _ in 0..MAX_ATTEMPTS {
let (current, revision, undecodable) = read_or_default::<Initial>(&store, agent)
.await
.map_err(|e| queue_problem(&e))?;
// An absent key reads as the default, which is not "stored": a first
// write of an empty text still has to land.
if current == value && !undecodable && revision.is_some() {
return Ok(None);
}
if let Some(written) = write_revision(&store, agent, &value, revision)
.await
.map_err(|e| queue_problem(&e))?
{
tracing::info!(%agent, bytes = content.len(), "set agent's initial AGENTS.md at create");
return Ok(Some(crate::schedules::Replaced::new(
revision.map(|_| current),
written,
)));
}
}
Err(queue_problem(&anyhow::anyhow!(
"gave up writing the initial AGENTS.md of {agent} after {MAX_ATTEMPTS} conflicting writes"
)))
}
/// Undo what [`store`] did, unless another write has landed since. Best
/// effort: a failure is logged, since the caller is already on its way to an
/// error of its own.
pub(crate) async fn restore(client: &async_nats::Client, agent: &str, replaced: Replaced) {
let Some(replaced) = replaced else { return };
let undone = match agents_md_initial::open_or_create(client).await {
Ok(store) => replaced.undo(&store, agent).await,
Err(e) => Err(anyhow::anyhow!("{e:?}")),
};
match undone {
Ok(true) => {}
Ok(false) => {
tracing::info!(%agent, "left the agent's initial AGENTS.md: another write landed since");
}
Err(e) => {
tracing::warn!(%agent, error = %format!("{e:#}"), "could not put back the agent's initial AGENTS.md");
}
}
}
/// Delete a destroyed agent's key, so the next agent of that name does not
/// start from this one's text. A failure is logged and does not stop the
/// destroy, as in `agent_archetype::clear_on_destroy`.
pub(crate) async fn clear_on_destroy(state: &AppState, hive: &str, agent: &str) {
let cleared = async {
let client = queue(state).map_err(|(_, why)| anyhow::anyhow!(why))?;
agents_md_initial::open_or_create(&client)
.await?
.delete(agent)
.await?;
anyhow::Ok(())
}
.await;
match cleared {
Ok(()) => tracing::info!(hive, agent, "destroyed agent's initial AGENTS.md cleared"),
Err(e) => tracing::error!(
hive,
agent,
error = %format!("{e:#}"),
"clearing this agent's initial AGENTS.md FAILED; it was destroyed anyway, so a new \
agent of the same name starts from this text. Declaring the agent destroyed again \
re-runs this."
),
}
}
#[cfg(test)]
mod tests {
use axum::extract::{Path, State};
use axum::http::StatusCode;
use super::*;
use crate::test_nats::TestNats;
async fn published(client: &async_nats::Client, agent: &str) -> Option<String> {
let store = agents_md_initial::open_or_create(client)
.await
.expect("bucket");
store
.get(agent)
.await
.expect("read")
.map(|v| String::from_utf8(v.to_vec()).expect("utf-8"))
}
fn request(name: &str, agents_md: Option<String>) -> crate::routes::agents::CreateAgentRequest {
crate::routes::agents::CreateAgentRequest {
name: name.to_owned(),
hive: "pr1ma".to_owned(),
limits: None,
inference: None,
permissions: None,
archetype: None,
agents_md,
}
}
#[test]
fn a_text_over_the_limit_is_refused_and_one_at_it_passes() {
assert_eq!(check(&"a".repeat(MAX_BYTES)), Ok(()));
let why = check(&"a".repeat(MAX_BYTES + 1)).unwrap_err();
assert!(why.contains("the limit is"), "{why}");
}
/// `POST /api/agents` with `agents_md` stores it under the new agent's
/// key; a text over the limit is refused before anything is stored or
/// queued.
#[tokio::test]
async fn create_stores_the_text_it_is_given_and_refuses_one_too_big() {
let nats = TestNats::start();
let client = nats.client().await;
let state = crate::agent_limits::tests::declared(&client).await;
let problem = crate::routes::agents::create_agent(
State(state.clone()),
axum::Json(request("iris", Some("a".repeat(MAX_BYTES + 1)))),
)
.await
.expect_err("too big");
assert_eq!(problem.status, Some(StatusCode::BAD_REQUEST), "{problem:?}");
assert_eq!(published(&client, "iris").await, None);
let queued = state
.jobq
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.graph()
.nodes()
.count();
assert_eq!(queued, 0, "nothing is queued for a refused create");
let _ = crate::routes::agents::create_agent(
State(state.clone()),
axum::Json(request("iris", Some("# iris\n".to_owned()))),
)
.await
.expect("created");
assert_eq!(
published(&client, "iris").await.as_deref(),
Some(r##"{"content":"# iris\n"}"##)
);
// Control: no `agents_md` writes no key.
let _ = crate::routes::agents::create_agent(
State(state.clone()),
axum::Json(request("damocles", None)),
)
.await
.expect("created");
assert_eq!(published(&client, "damocles").await, None);
}
/// An empty text is a text: the key is written, so the agent gets an
/// empty file rather than none.
#[tokio::test]
async fn an_empty_text_is_stored() {
let nats = TestNats::start();
let client = nats.client().await;
let state = crate::agent_limits::tests::declared(&client).await;
let _ = crate::routes::agents::create_agent(
State(state.clone()),
axum::Json(request("iris", Some(String::new()))),
)
.await
.expect("created");
assert_eq!(
published(&client, "iris").await.as_deref(),
Some(r#"{"content":""}"#)
);
}
/// Destroying an agent deletes its key, so a new agent of that name does
/// not start from the old text.
#[tokio::test]
async fn a_recreated_agent_does_not_start_from_the_destroyed_ones_text() {
let nats = TestNats::start();
let client = nats.client().await;
let state = crate::agent_limits::tests::declared(&client).await;
let _ = crate::routes::agents::create_agent(
State(state.clone()),
axum::Json(request("atlas", Some("old".to_owned()))),
)
.await
.expect("created");
assert!(published(&client, "atlas").await.is_some());
let _ = crate::routes::agents::set_agent_state(
State(state.clone()),
Path(("pr1ma".to_owned(), "atlas".to_owned())),
axum::Json(crate::routes::agents::SetAgentStateRequest {
state: swarm_queue_client::wanted::AgentState::Destroyed,
}),
)
.await
.expect("destroyed");
assert_eq!(published(&client, "atlas").await, None);
}
}

View file

@ -1688,6 +1688,7 @@ mod tests {
inference: None,
permissions: None,
archetype: None,
agents_md: None,
}),
)
.await
@ -1810,6 +1811,7 @@ mod tests {
inference: None,
permissions: None,
archetype: None,
agents_md: None,
}),
)
.await
@ -1861,6 +1863,7 @@ mod tests {
inference: None,
permissions: None,
archetype: None,
agents_md: None,
}),
)
.await
@ -1920,6 +1923,7 @@ mod tests {
inference: None,
permissions: None,
archetype: None,
agents_md: None,
}),
)
.await
@ -2126,6 +2130,7 @@ mod tests {
inference: None,
permissions: None,
archetype: None,
agents_md: None,
}),
)
.await

View file

@ -33,6 +33,7 @@ mod agent_renewal;
mod agent_state_stream;
mod agent_status;
mod agents_md;
mod agents_md_initial;
mod alerts;
mod archetypes;
mod auth;

View file

@ -16,8 +16,8 @@ use crate::jobq::{SwarmNodeKind, SwarmResourceKind, declare_agent_job};
use crate::routes::hives::HiveEntry;
use crate::routes::{AppState, StatusUnavailable, error_problem, wanted_error_status};
use crate::{
agent_archetype, agent_identity, agent_limits, agent_permissions, agent_status, auth,
inference, schedules, wanted,
agent_archetype, agent_identity, agent_limits, agent_permissions, agent_status,
agents_md_initial, auth, inference, schedules, wanted,
};
/// The swarm's agent roster — every agent the swarm holds an identity for.
@ -214,6 +214,7 @@ pub(crate) async fn set_agent_state(
revoke_or_complain(&hive, &agent).await;
agent_permissions::clear_on_destroy(&state, &hive, &agent).await;
agent_archetype::clear_on_destroy(&state, &hive, &agent).await;
agents_md_initial::clear_on_destroy(&state, &hive, &agent).await;
}
Ok(Json(render(&declaration)))
}
@ -516,6 +517,13 @@ pub(crate) struct CreateAgentRequest {
#[serde(default)]
#[schema(value_type = Option<Object>, example = json!({"source": "hyperhive", "name": "reviewer"}))]
pub(crate) archetype: Option<swarm_queue_client::agent_archetype::ArchetypeRef>,
/// The text the agent's `AGENTS.md` starts with, at most
/// `swarm_queue_client::agents_md_initial::MAX_BYTES` bytes. The agent's
/// harness writes it on its first start, and only when the agent has no
/// `AGENTS.md` yet: an existing file is never replaced. Absent leaves the
/// file to the harness.
#[serde(default)]
pub(crate) agents_md: Option<String>,
}
/// Queue the whole agent-creation job graph for `name` — two independent
@ -549,10 +557,10 @@ pub(crate) struct CreateAgentRequest {
request_body = CreateAgentRequest,
responses(
(status = 200, description = "job chain queued", body = CreateAgentResponse),
(status = 400, description = "`name` or `hive` is not a valid identifier, `name` is new and reserved or refused by the forge, `hive` is not in this swarm, `limits` or `inference` holds a value the matching `PUT` refuses, a permission is listed twice, or `archetype` is not one the stored archetype list holds (problem+json)", body = String),
(status = 400, description = "`name` or `hive` is not a valid identifier, `name` is new and reserved or refused by the forge, `hive` is not in this swarm, `limits` or `inference` holds a value the matching `PUT` refuses, a permission is listed twice, `archetype` is not one the stored archetype list holds, or `agents_md` is over the size limit (problem+json)", body = String),
(status = 409, description = "the swarm has already placed `name` on a different hive (problem+json)", body = String),
(status = 422, description = "an unknown field in the body, in `limits`, or in `inference`, an `effort` that is not one of the five levels, or a `permissions` entry that is not a permission name"),
(status = 503, description = "the swarm queue is not connected, so whether `name` is placed on another hive is unknown, or `limits`, `inference`, `permissions` or `archetype` is set and cannot be stored (problem+json)", body = String),
(status = 503, description = "the swarm queue is not connected, so whether `name` is placed on another hive is unknown, or `limits`, `inference`, `permissions`, `archetype` or `agents_md` is set and cannot be stored (problem+json)", body = String),
(status = 500, description = "the job chain could not be queued, or another hive's wanted state could not be read (problem+json)", body = String),
),
tag = "agents"
@ -567,7 +575,7 @@ pub(crate) async fn create_agent(
// Settings are checked before anything is created, so a bad value stops
// the request rather than leaving an agent without them.
let settings = CreateSettings::parse(req.limits, req.inference, req.permissions, req.archetype)
let settings = CreateSettings::parse(&req)
.map_err(|why| error_problem(axum::http::StatusCode::BAD_REQUEST, &why))?;
// Two checks, and the second is the one that makes the field worth
@ -665,6 +673,7 @@ pub(crate) async fn create_agent(
settings.inference.as_ref(),
settings.permissions.as_ref(),
settings.archetype.as_ref(),
settings.agents_md.as_deref(),
)
.await?
}
@ -706,18 +715,25 @@ struct CreateSettings {
inference: Option<swarm_queue_client::agent_inference::AgentInference>,
permissions: Option<swarm_queue_client::agent_permissions::AgentPermissions>,
archetype: Option<swarm_queue_client::agent_archetype::ArchetypeRef>,
agents_md: Option<String>,
}
impl CreateSettings {
/// Each setting given, or why its `PUT` would refuse the first bad one.
/// An archetype is checked here for its shape only; whether the swarm
/// lists it is [`agent_archetype::check`].
fn parse(
limits: Option<agent_limits::Caps>,
inference: Option<inference::InferenceSettings>,
permissions: Option<Vec<hive_sh4re::permissions::Permission>>,
archetype: Option<swarm_queue_client::agent_archetype::ArchetypeRef>,
) -> Result<Self, String> {
fn parse(req: &CreateAgentRequest) -> Result<Self, String> {
let CreateAgentRequest {
limits,
inference,
permissions,
archetype,
agents_md,
..
} = req.clone();
if let Some(text) = &agents_md {
agents_md_initial::check(text)?;
}
if let Some(a) = &archetype {
for part in [&a.source, &a.name] {
hive_types::Ident::parse(part)
@ -731,6 +747,7 @@ impl CreateSettings {
.transpose()?,
permissions: permissions.map(agent_permissions::at_create).transpose()?,
archetype,
agents_md,
})
}
@ -740,6 +757,7 @@ impl CreateSettings {
|| self.inference.is_some()
|| self.permissions.is_some()
|| self.archetype.is_some()
|| self.agents_md.is_some()
}
/// The queue to store the settings through, `None` when none is given.
@ -767,10 +785,11 @@ struct StoredSettings {
inference: inference::Replaced,
permissions: agent_permissions::Replaced,
archetype: agent_archetype::Replaced,
agents_md: agents_md_initial::Replaced,
}
impl StoredSettings {
/// Store whichever of `limits`, `permissions`, `inference` and `archetype` are
/// Store whichever of `limits`, `permissions`, `inference`, `archetype` and `agents_md` are
/// given, in that order. A failure on one puts back the ones stored before it.
async fn store(
client: &async_nats::Client,
@ -779,6 +798,7 @@ impl StoredSettings {
inference: Option<&swarm_queue_client::agent_inference::AgentInference>,
permissions: Option<&swarm_queue_client::agent_permissions::AgentPermissions>,
archetype: Option<&swarm_queue_client::agent_archetype::ArchetypeRef>,
agents_md: Option<&str>,
) -> Result<Self, problem_details::ProblemDetails> {
let mut stored = Self::default();
if let Some(limits) = limits {
@ -811,6 +831,15 @@ impl StoredSettings {
}
}
}
if let Some(text) = agents_md {
match agents_md_initial::store(client, agent, text).await {
Ok(replaced) => stored.agents_md = replaced,
Err(e) => {
stored.roll_back(client, agent).await;
return Err(e);
}
}
}
Ok(stored)
}
@ -819,6 +848,7 @@ impl StoredSettings {
inference::restore(client, agent, self.inference).await;
agent_permissions::restore(client, agent, self.permissions).await;
agent_archetype::restore(client, agent, self.archetype).await;
agents_md_initial::restore(client, agent, self.agents_md).await;
}
}
@ -1238,6 +1268,7 @@ mod tests {
inference: None,
permissions: None,
archetype: None,
agents_md: None,
}),
)
.await
@ -1280,6 +1311,7 @@ mod tests {
inference: None,
permissions: None,
archetype: None,
agents_md: None,
}),
)
.await
@ -1444,6 +1476,7 @@ mod tests {
Some(&inference),
None,
None,
None,
)
.await;
assert!(stored.is_err(), "{agent}: the inference write must fail");
@ -1583,6 +1616,7 @@ mod tests {
inference: None,
permissions: None,
archetype: None,
agents_md: None,
}),
)
.await