set an agent's initial agents.md at create
This commit is contained in:
parent
a6147dc875
commit
456cf9a7cb
19 changed files with 708 additions and 19 deletions
|
|
@ -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
|
||||
|
|
|
|||
140
swarm-queue-client/src/agents_md_initial.rs
Normal file
140
swarm-queue-client/src/agents_md_initial.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
Loading…
Reference in a new issue