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

@ -62,6 +62,10 @@ agent-permissions = ["dep:hive-sh4re", "async-nats/jetstream"]
# `agent_archetype::read`, for hive-c0re, which reads the archetype of each
# agent it provisions or rebuilds. Explicit for the same reason as `notices`.
agent-archetype = ["async-nats/jetstream"]
# `agents_md_initial::read_own`, for the agent harness, which reads its own
# initial `AGENTS.md` text with one direct get. Explicit for the same reason as
# `notices`.
agents-md-initial = ["async-nats/jetstream"]
[dependencies]
# Bare (no `kv`/`jetstream`) unless a consumer opts into the `kv` feature

View file

@ -0,0 +1,140 @@
//! The text an agent's `AGENTS.md` starts with: one key per agent in the
//! `agents-md-initial` KV bucket, set when the agent is created.
//!
//! **swarm-controller is the only writer**: `POST /api/agents` stores the key
//! before it queues the agent's creation, and a destroy deletes it.
//!
//! **The agent's harness reads its own key** when it starts, and writes the
//! text to `AGENTS.md` only when the file does not exist. The file in the
//! agent's state dir stays the truth ([`crate::agents_md`]); this key is not
//! read again once the file exists, and later edits never reach it.
//!
//! Keyed by the agent alone, like [`crate::agent_archetype`]. The agent's
//! credential is granted one direct get on its own key and nothing else of
//! the bucket.
/// The KV bucket the initial texts are stored in, keyed by agent name.
pub const BUCKET: &str = "agents-md-initial";
/// The stream backing [`BUCKET`]. `create_key_value` prefixes `KV_`.
pub const STREAM: &str = "KV_agents-md-initial";
/// The longest initial text, in bytes. The agent mirrors the file into
/// [`crate::agents_md`] whole, so the limit keeps it well under the queue's
/// payload limit.
pub const MAX_BYTES: usize = 64 * 1024;
/// The subject `agent`'s entry is stored under.
#[must_use]
pub fn key_subject(agent: &str) -> String {
format!("$KV.{BUCKET}.{agent}")
}
/// The request subject that reads `agent`'s current entry.
#[must_use]
pub fn direct_get_subject(agent: &str) -> String {
format!("$JS.API.DIRECT.GET.{STREAM}.{}", key_subject(agent))
}
/// The value under an agent's key.
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Initial {
/// The text `AGENTS.md` starts with.
pub content: String,
}
/// Open the bucket for writing, creating it if nothing has yet.
///
/// **Controller-side only.** `history: 1`: the agent wants the current value.
#[cfg(feature = "kv")]
pub async fn open_or_create(
client: &async_nats::Client,
) -> Result<async_nats::jetstream::kv::Store, crate::Error> {
let js = async_nats::jetstream::new(client.clone());
crate::kv::open_or_create(
&js,
BUCKET,
"agents-md-initial",
"The text each agent's AGENTS.md starts with, set by the swarm controller".to_owned(),
)
.await
}
/// Read `agent`'s own entry with one direct get. `None` when the key is
/// absent or deleted, or when no bucket exists yet.
///
/// # Errors
///
/// A failed request or an undecodable value. A request the queue refused for
/// permissions reaches the client as a timeout, not as a refusal.
#[cfg(feature = "agents-md-initial")]
pub async fn read_own(
client: &async_nats::Client,
agent: &str,
) -> Result<Option<Initial>, Box<dyn std::error::Error + Send + Sync>> {
use async_nats::jetstream::stream::DirectGetErrorKind;
let js = async_nats::jetstream::new(client.clone());
let stream = js.get_stream_no_info(STREAM).await?;
let message = match stream.direct_get_last_for_subject(key_subject(agent)).await {
Ok(message) => message,
Err(e) if matches!(e.kind(), DirectGetErrorKind::NotFound) => return Ok(None),
Err(e)
if matches!(
e.kind(),
DirectGetErrorKind::ErrorResponse(status, _)
if status == async_nats::StatusCode::NO_RESPONDERS
) =>
{
return Ok(None);
}
Err(e) => return Err(e.into()),
};
Ok(decode(
message
.headers
.get("KV-Operation")
.map(async_nats::HeaderValue::as_str),
&message.payload,
)?)
}
/// One stored message as a value: a KV delete or purge is stored as an empty
/// marker carrying the `KV-Operation` header, and reads as no entry.
#[cfg(feature = "agents-md-initial")]
fn decode(operation: Option<&str>, payload: &[u8]) -> serde_json::Result<Option<Initial>> {
if matches!(operation, Some("DEL" | "PURGE")) {
return Ok(None);
}
serde_json::from_slice(payload).map(Some)
}
#[cfg(test)]
mod tests {
use super::*;
/// The names the grant in `swarm-nats-auth` is built from.
#[test]
fn names_match_the_queue_grant() {
assert_eq!(STREAM, format!("KV_{BUCKET}"));
assert_eq!(key_subject("iris"), "$KV.agents-md-initial.iris");
assert_eq!(
direct_get_subject("iris"),
"$JS.API.DIRECT.GET.KV_agents-md-initial.$KV.agents-md-initial.iris"
);
}
/// A stored text reads back, a delete is no entry.
#[cfg(feature = "agents-md-initial")]
#[test]
fn decoding_reads_the_text_and_a_delete_as_none() {
assert_eq!(
decode(None, br##"{"content":"# hi\n","later":1}"##).unwrap(),
Some(Initial {
content: "# hi\n".to_owned()
})
);
assert_eq!(decode(Some("DEL"), b"").unwrap(), None);
assert_eq!(decode(Some("PURGE"), b"").unwrap(), None);
}
}

View file

@ -214,6 +214,11 @@ pub mod agent_icon;
/// `kv`.
pub mod agents_md;
/// The text each agent's `AGENTS.md` starts with, set at create. The names and
/// types are unconditional; opening the bucket needs `kv`, reading it
/// `agents-md-initial`.
pub mod agents_md_initial;
/// The bucket swarm-controller declares each agent's scheduled prompts in —
/// one key per agent, read by that agent alone. The names are unconditional;
/// writing needs `kv` and reading needs `agent-schedules`.