Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ docs/book/
# OS junk
**/.DS_Store
Thumbs.db
.taskter/
36 changes: 36 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ cargo run -p rlp -- run examples/openings/note_taker.yaml --local \
--params '{"prompt":"draft a standup note"}'
```

Install a prebuilt bundle into a registry dir (directory or `.tar`/`.tar.gz`):

```bash
rlp agent install /path/to/agent.bundle.tar
```

### Packages & images (daemon / system mode)

When installed from a .deb or image, the service runs as **`runloop:runloop`**
Expand Down
6 changes: 4 additions & 2 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -759,7 +759,9 @@ disable integration via a documented toggle.
- [ ] Keep `rlp agent scaffold/build` in the `.deb` and ensure it writes into a
search dir the executors already honor (system: `/var/lib/runloop/agents`
or a user-configured override; user mode: `~/.runloop/agents`).
- [ ] Add a small helper (`rlp agent list`) that enumerates discovered bundles
- [x] Add `rlp agent install` for local bundles (`dir|.tar|.tar.gz`) with
manifest digest + tools.json validation (signature verification pending).
- [x] Add a small helper (`rlp agent list`) that enumerates discovered bundles
with their source dir and digest status so users can verify visibility.
- [ ] Update postinst or first-run guidance to create the default search dirs
with correct ownership/permissions for the `runloop` user/group.
Expand All @@ -774,7 +776,7 @@ disable integration via a documented toggle.

### R5. Docs

- [ ] Refresh `docs/authoring-on-deb.md` and `README.md` to remove the
- [x] Refresh `docs/authoring-on-deb.md` and `README.md` to remove the
“demo-only” caveat, document search-dir defaults, and show how to override
them in both modes.
- [ ] Add a troubleshooting snippet for permission issues (daemon user cannot
Expand Down
133 changes: 133 additions & 0 deletions crates/agent-registry/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,12 @@ impl AgentRegistry {
fn find_manifest(&self, reference: &AgentRef) -> Result<PathBuf, AgentRegistryError> {
let relative_roots = candidate_roots(reference);
for base in &self.search_dirs {
if let Some(candidate) = manifest_at_search_root(base, reference)? {
return Ok(candidate);
}
if base.is_file() {
continue;
}
for rel in &relative_roots {
let candidate = base.join(rel).join("manifest.toml");
if candidate.is_file() {
Expand Down Expand Up @@ -248,6 +254,43 @@ fn candidate_roots(reference: &AgentRef) -> Vec<PathBuf> {
roots
}

fn manifest_at_search_root(
base: &Path,
reference: &AgentRef,
) -> Result<Option<PathBuf>, AgentRegistryError> {
let candidate = if base.is_file() {
if base.file_name().is_some_and(|name| name == "manifest.toml") {
base.to_path_buf()
} else {
return Ok(None);
}
} else {
let candidate = base.join("manifest.toml");
if !candidate.is_file() {
return Ok(None);
}
candidate
};

let (_, doc) = match read_manifest(&candidate) {
Ok(result) => result,
Err(_) => return Ok(None),
};
if doc.agent.name != reference.name {
return Ok(None);
}
if let Some(ref_variant) = &reference.variant {
let Some(man_variant) = &doc.agent.variant else {
return Ok(None);
};
if man_variant != ref_variant {
return Ok(None);
}
}

Ok(Some(candidate))
}

fn read_manifest(path: &Path) -> Result<(String, ManifestDoc), AgentRegistryError> {
let raw = fs::read_to_string(path).map_err(|source| AgentRegistryError::Io {
path: path.to_path_buf(),
Expand Down Expand Up @@ -471,6 +514,12 @@ mod tests {
manifest_path
}

fn write_root_manifest(root: &Path, manifest: &str) -> PathBuf {
let manifest_path = root.join("manifest.toml");
std::fs::write(&manifest_path, manifest).expect("write manifest");
manifest_path
}

fn basic_manifest() -> &'static str {
r#"[agent]
name = "writer"
Expand All @@ -482,6 +531,35 @@ out = []
"#
}

fn other_manifest() -> &'static str {
r#"[agent]
name = "other"
version = "1.0.0"

[ports]
in = []
out = []
"#
}

fn variant_manifest() -> &'static str {
r#"[agent]
name = "writer"
version = "1.1.0"
variant = "pro"

[ports]
in = []
out = []
"#
}

fn invalid_manifest() -> &'static str {
r#"[agent
name = "writer"
"#
}

#[test]
fn describe_loads_manifest_schema() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
Expand Down Expand Up @@ -612,6 +690,61 @@ out = []
assert_eq!(listed[0].manifest_path, manifest_path);
}

#[test]
fn bundle_resolves_manifest_at_search_root() {
let tmp = tempdir().expect("tmp dir");
write_root_manifest(tmp.path(), basic_manifest());
let registry = AgentRegistry::new([tmp.path().to_path_buf()]);
let bundle = registry
.bundle(&AgentRef::new("writer", None))
.expect("bundle");
assert_eq!(bundle.manifest_dir, tmp.path());
assert_eq!(bundle.described.reference.name, "writer");
}

#[test]
fn bundle_skips_invalid_root_manifest() {
let tmp = tempdir().expect("tmp dir");
write_root_manifest(tmp.path(), invalid_manifest());
write_manifest(tmp.path(), basic_manifest());
let registry = AgentRegistry::new([tmp.path().to_path_buf()]);
let bundle = registry
.bundle(&AgentRef::new("writer", None))
.expect("bundle");
assert_eq!(bundle.described.reference.name, "writer");
assert!(bundle.manifest_dir.ends_with("writer"));
}

#[test]
fn bundle_ignores_root_manifest_for_other_agent() {
let tmp = tempdir().expect("tmp dir");
write_root_manifest(tmp.path(), other_manifest());
write_manifest(tmp.path(), basic_manifest());
let registry = AgentRegistry::new([tmp.path().to_path_buf()]);
let bundle = registry
.bundle(&AgentRef::new("writer", None))
.expect("bundle");
assert_eq!(bundle.described.reference.name, "writer");
assert!(bundle.manifest_dir.ends_with("writer"));
}

#[test]
fn bundle_ignores_root_manifest_without_variant_for_variant_request() {
let tmp = tempdir().expect("tmp dir");
write_root_manifest(tmp.path(), basic_manifest());
let variant_dir = tmp.path().join("writer").join("variants").join("pro");
std::fs::create_dir_all(&variant_dir).expect("variant dir");
std::fs::write(variant_dir.join("manifest.toml"), variant_manifest())
.expect("write manifest");

let registry = AgentRegistry::new([tmp.path().to_path_buf()]);
let bundle = registry
.bundle(&AgentRef::new("writer", Some("pro".into())))
.expect("bundle");
assert_eq!(bundle.manifest_dir, variant_dir);
assert_eq!(bundle.described.reference.variant.as_deref(), Some("pro"));
}

#[test]
fn bundle_exposes_tools_attachment() {
let tmp = tempdir().expect("tmp dir");
Expand Down
4 changes: 3 additions & 1 deletion crates/rlp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ uuid = { version = "1", features = ["v4"] }
url = "2.5"
toml = "0.9"
toml_edit = "0.23"
flate2 = "1.0"
tar = "0.4"
tempfile = "3"

[dev-dependencies]
tempfile = "3"

[package.metadata.deb]
maintainer = "Runloop Authors <ops@runloop.media>"
Expand Down
Loading
Loading