brokerd: ask_memory, a built-in memory tool that memoryd runs
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
//! path, a host or a URL is well formed. The module is pure: no I/O, no clock. Everything it reads
|
||||
//! was written by the model, so it is treated as hostile.
|
||||
|
||||
use bxcore::AskMemoryArgs;
|
||||
use bxcore::tools::{HttpFetchArgs, ReadFileArgs, ShellArgs, WriteFileArgs};
|
||||
use bxcore::{MemoryArgs, RecallArgs};
|
||||
use std::fmt;
|
||||
@@ -15,8 +16,8 @@ pub const MAX_URL: usize = 2048;
|
||||
/// Maximum length, in characters, of the detail an invalid call is denied with.
|
||||
pub const MAX_DETAIL: usize = 300;
|
||||
|
||||
/// The tools the broker can dispatch to: four that run in a container, and the two memory
|
||||
/// tools that `memoryd` runs (M5a spec, section 8).
|
||||
/// The tools the broker can dispatch to: four that run in a container, and the three memory
|
||||
/// tools that `memoryd` runs (M5a spec, section 8; M5c spec, section 9).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ToolName {
|
||||
ReadFile,
|
||||
@@ -25,6 +26,7 @@ pub enum ToolName {
|
||||
HttpFetch,
|
||||
Recall,
|
||||
Memory,
|
||||
AskMemory,
|
||||
}
|
||||
|
||||
impl ToolName {
|
||||
@@ -45,6 +47,7 @@ impl ToolName {
|
||||
"http_fetch" => Some(ToolName::HttpFetch),
|
||||
"recall" => Some(ToolName::Recall),
|
||||
"memory" => Some(ToolName::Memory),
|
||||
"ask_memory" => Some(ToolName::AskMemory),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -58,12 +61,16 @@ impl ToolName {
|
||||
ToolName::HttpFetch => "http_fetch",
|
||||
ToolName::Recall => "recall",
|
||||
ToolName::Memory => "memory",
|
||||
ToolName::AskMemory => "ask_memory",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `memoryd` runs this tool rather than a container.
|
||||
pub fn is_memory(self) -> bool {
|
||||
matches!(self, ToolName::Recall | ToolName::Memory)
|
||||
matches!(
|
||||
self,
|
||||
ToolName::Recall | ToolName::Memory | ToolName::AskMemory
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +91,7 @@ pub enum ToolArgs {
|
||||
HttpFetch(FetchUrl),
|
||||
Recall(RecallArgs),
|
||||
Memory(MemoryArgs),
|
||||
AskMemory(AskMemoryArgs),
|
||||
}
|
||||
|
||||
/// A URL that passed the checks, and its host. Only `parse` makes one, so the host policy matched
|
||||
@@ -128,6 +136,7 @@ impl ToolArgs {
|
||||
ToolArgs::HttpFetch(_) => ToolName::HttpFetch,
|
||||
ToolArgs::Recall(_) => ToolName::Recall,
|
||||
ToolArgs::Memory(_) => ToolName::Memory,
|
||||
ToolArgs::AskMemory(_) => ToolName::AskMemory,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +176,9 @@ impl ToolArgs {
|
||||
ToolArgs::Memory(value) => {
|
||||
serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
ToolArgs::AskMemory(value) => {
|
||||
serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -284,6 +296,12 @@ pub fn parse(tool: ToolName, arguments: &str) -> Result<ToolArgs, ArgsError> {
|
||||
value.check().map_err(ArgsError::Shape)?;
|
||||
Ok(ToolArgs::Memory(value))
|
||||
}
|
||||
ToolName::AskMemory => {
|
||||
let value: AskMemoryArgs = serde_json::from_str(arguments)
|
||||
.map_err(|error| ArgsError::Shape(error.to_string()))?;
|
||||
value.check().map_err(ArgsError::Shape)?;
|
||||
Ok(ToolArgs::AskMemory(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The built-in grants of `recall` and `memory` (M5a spec, section 8). Reading and writing memory
|
||||
//! is what an agent is, so no grant file can turn it off or put questions in front of it; the
|
||||
//! prompt is protected by `memoryd`, which lets only clean sessions edit identity.
|
||||
//! The built-in grants of `recall`, `memory` and `ask_memory` (M5a spec, section 8; M5c spec,
|
||||
//! section 9). Reading and writing memory is what an agent is, so no grant file can turn it off or
|
||||
//! put questions in front of it; the prompt is protected by `memoryd`, which lets only clean
|
||||
//! sessions edit identity.
|
||||
|
||||
use bxcore::{Grant, GrantProblem, sha256};
|
||||
|
||||
@@ -12,17 +13,23 @@ pub const RESERVED: &str = "builtin-";
|
||||
/// The built-in grants' text, hashed for the audit log like a grant file's bytes.
|
||||
pub const RECALL: &str = "tool = \"recall\"\nmode = \"auto\"\nmax_taint = \"secret\"\nresult_class = \"private\"\nuntrusted = false\n";
|
||||
pub const MEMORY: &str = "tool = \"memory\"\nmode = \"auto\"\nmax_taint = \"secret\"\nresult_class = \"private\"\nuntrusted = false\n";
|
||||
pub const ASK_MEMORY: &str = "tool = \"ask_memory\"\nmode = \"auto\"\nmax_taint = \"secret\"\nresult_class = \"private\"\nuntrusted = false\n";
|
||||
|
||||
/// The sentence for an owner's grant naming a memory tool.
|
||||
pub const BUILT_IN: &str = "the policy of recall and memory is built in; remove this grant";
|
||||
pub const BUILT_IN: &str =
|
||||
"the policy of recall, memory and ask_memory is built in; remove this grant";
|
||||
|
||||
/// The two built-in grants, `builtin-memory` and `builtin-recall`.
|
||||
/// The three built-in grants, `builtin-memory`, `builtin-recall` and `builtin-ask-memory`.
|
||||
pub fn grants() -> Result<Vec<LoadedGrant>, GrantProblem> {
|
||||
// For each (id, text) of [("builtin-memory", MEMORY), ("builtin-recall", RECALL)]:
|
||||
// For each (id, text) of `sources`:
|
||||
// `toml::from_str::<Grant>(text)` and `sha256(text.as_bytes())`, each error as a GrantProblem {
|
||||
// file: id, line: None, problem: e.to_string() }; build LoadedGrant { id, grant, sha256 }.
|
||||
// Collect into Result<Vec<LoadedGrant>, GrantProblem> (the first error wins).
|
||||
let sources: &[(&str, &str)] = &[("builtin-memory", MEMORY), ("builtin-recall", RECALL)];
|
||||
let sources: &[(&str, &str)] = &[
|
||||
("builtin-memory", MEMORY),
|
||||
("builtin-recall", RECALL),
|
||||
("builtin-ask-memory", ASK_MEMORY),
|
||||
];
|
||||
let mut out: Vec<LoadedGrant> = Vec::new();
|
||||
for (id, text) in sources {
|
||||
let problem = |error: String| GrantProblem {
|
||||
|
||||
@@ -124,7 +124,9 @@ impl Runner {
|
||||
crate::args::ToolName::Shell => self.shell_ms,
|
||||
crate::args::ToolName::HttpFetch => self.http_fetch_ms,
|
||||
// `memoryd` runs these; they never reach a container.
|
||||
crate::args::ToolName::Recall | crate::args::ToolName::Memory => self.read_file_ms,
|
||||
crate::args::ToolName::Recall
|
||||
| crate::args::ToolName::Memory
|
||||
| crate::args::ToolName::AskMemory => self.read_file_ms,
|
||||
};
|
||||
std::time::Duration::from_millis(ms)
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ fn covers(args: &ToolArgs, grant: &Grant) -> Option<Option<String>> {
|
||||
}
|
||||
ToolArgs::Shell { cwd: Some(cwd), .. } => best_path(grant, cwd, true),
|
||||
// The memory tools take no constraints: their grants are built in (M5a spec, section 8).
|
||||
ToolArgs::Recall(_) | ToolArgs::Memory(_) => Some(None),
|
||||
ToolArgs::Recall(_) | ToolArgs::Memory(_) | ToolArgs::AskMemory(_) => Some(None),
|
||||
ToolArgs::HttpFetch(target) => {
|
||||
if grant
|
||||
.constraints
|
||||
|
||||
@@ -129,7 +129,7 @@ pub fn run(decision: Decision, runtime: &dyn Runtime) -> bxcore::ToolResponse {
|
||||
None,
|
||||
),
|
||||
ToolArgs::HttpFetch(_) => (Vec::new(), Some(decision.hosts().to_vec())),
|
||||
ToolArgs::Recall(_) | ToolArgs::Memory(_) => (Vec::new(), None),
|
||||
ToolArgs::Recall(_) | ToolArgs::Memory(_) | ToolArgs::AskMemory(_) => (Vec::new(), None),
|
||||
};
|
||||
let spec = RunSpec {
|
||||
session: decision.request().session.clone(),
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
//! `ask_memory` in `brokerd`: its name and arguments, the built-in grant that allows it in every
|
||||
//! session, grant files that name it refused, its calls routed to `memoryd` and never to a
|
||||
//! container, and the labels of `memoryd`'s answer raising the session (M5c spec, section 9).
|
||||
//! Do not edit.
|
||||
|
||||
#[path = "support/build.rs"]
|
||||
mod build;
|
||||
#[path = "support/client.rs"]
|
||||
mod client;
|
||||
#[path = "support/fake_podman.rs"]
|
||||
mod fake_podman;
|
||||
#[path = "support/rig.rs"]
|
||||
mod rig;
|
||||
#[path = "support/runtime.rs"]
|
||||
mod runtime;
|
||||
#[path = "support/sink.rs"]
|
||||
mod sink;
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
use brokerd::args::{ArgsError, ToolArgs, ToolName, parse};
|
||||
use brokerd::broker::Broker;
|
||||
use brokerd::builtin::{ASK_MEMORY, BUILT_IN};
|
||||
use brokerd::container::{IN_MEMORYD, Podman};
|
||||
use brokerd::grants::load;
|
||||
use brokerd::memory_port::{MemoryPort, Routed};
|
||||
use brokerd::policy::{Label, SessionState, decide};
|
||||
use brokerd::runner::run;
|
||||
use build::{allowed, now, private, secret, set};
|
||||
use bxcore::{
|
||||
AskMemoryArgs, DataClass, Envelope, MemoryCall, Message, SessionId, ToolResponse, read_frame,
|
||||
sha256, write_frame,
|
||||
};
|
||||
use fake_podman::{Fake, Lines, serial};
|
||||
use rig::Rig;
|
||||
use runtime::{Recording, Shared};
|
||||
use tmp::TempDir;
|
||||
|
||||
/// A fake `memoryd`: answers one call with `answer` and hands back what it was sent. A failed
|
||||
/// write is ignored: the test has its answer or has already failed.
|
||||
fn fake_memoryd(socket: &Path, answer: ToolResponse) -> mpsc::Receiver<MemoryCall> {
|
||||
let listener = UnixListener::bind(socket).unwrap();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let Ok((mut stream, _)) = listener.accept() else {
|
||||
return;
|
||||
};
|
||||
let Ok(env) = read_frame(&mut stream) else {
|
||||
return;
|
||||
};
|
||||
let Message::MemoryCall(call) = env.msg else {
|
||||
return;
|
||||
};
|
||||
let _ = tx.send(call);
|
||||
let reply = Envelope {
|
||||
v: 1,
|
||||
id: env.id,
|
||||
r#final: true,
|
||||
msg: Message::ToolResponse(answer),
|
||||
};
|
||||
let _ = write_frame(&mut stream, &reply);
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
fn answer(class: DataClass, untrusted: bool) -> ToolResponse {
|
||||
ToolResponse::Result {
|
||||
content: "own: memory · The owner prefers UTC.".to_string(),
|
||||
class,
|
||||
untrusted,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn routed(socket: &Path, other: &Arc<Recording>) -> Routed {
|
||||
Routed {
|
||||
memory: MemoryPort::new(socket.to_path_buf(), Duration::from_secs(5)),
|
||||
other: Box::new(Shared(Arc::clone(other))),
|
||||
}
|
||||
}
|
||||
|
||||
const QUESTION: &str = r#"{"question":"What does the owner prefer?"}"#;
|
||||
|
||||
#[test]
|
||||
fn the_name_and_the_arguments() {
|
||||
assert_eq!(ToolName::parse("ask_memory"), Some(ToolName::AskMemory));
|
||||
assert_eq!(ToolName::AskMemory.as_str(), "ask_memory");
|
||||
assert!(ToolName::AskMemory.is_memory());
|
||||
assert!(
|
||||
!ToolName::ALL.contains(&ToolName::AskMemory),
|
||||
"ALL is the container tools"
|
||||
);
|
||||
let args = parse(
|
||||
ToolName::AskMemory,
|
||||
r#"{ "workspaces": ["fleet", "boxmaker1"], "question": "Who wrote it?" }"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(args.tool(), ToolName::AskMemory);
|
||||
assert_eq!(
|
||||
args,
|
||||
ToolArgs::AskMemory(AskMemoryArgs {
|
||||
question: "Who wrote it?".to_string(),
|
||||
workspaces: vec!["fleet".to_string(), "boxmaker1".to_string()],
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
args.canonical_json(),
|
||||
r#"{"question":"Who wrote it?","workspaces":["fleet","boxmaker1"]}"#
|
||||
);
|
||||
let default = parse(ToolName::AskMemory, QUESTION).unwrap();
|
||||
assert_eq!(
|
||||
default.canonical_json(),
|
||||
r#"{"question":"What does the owner prefer?","workspaces":[]}"#,
|
||||
"every field is written, as recall's k is"
|
||||
);
|
||||
}
|
||||
|
||||
/// The refusals are `bxcore`'s sentences, shown to the model after `invalid argument shape: `.
|
||||
#[test]
|
||||
fn bad_arguments_are_refused_with_the_checks_sentences() {
|
||||
let long = format!(r#"{{"question":"{}"}}"#, "é".repeat(2_001));
|
||||
for (json, sentence) in [
|
||||
(
|
||||
r#"{"question":" "}"#,
|
||||
"question is empty; write the question to ask",
|
||||
),
|
||||
(
|
||||
long.as_str(),
|
||||
"question has 2001 characters, over the limit of 2000; ask a shorter question",
|
||||
),
|
||||
(
|
||||
r#"{"question":"q","workspaces":["own","untrusted","fleet"]}"#,
|
||||
"workspaces names 3, over the limit of 2; ask about the others in another call",
|
||||
),
|
||||
(
|
||||
r#"{"question":"q","workspaces":["fleet","fleet"]}"#,
|
||||
"workspaces names fleet twice; name each workspace once",
|
||||
),
|
||||
(
|
||||
r#"{"question":"q","workspaces":[""]}"#,
|
||||
"a workspace name is empty; name a workspace, or leave workspaces out to ask own",
|
||||
),
|
||||
(
|
||||
r#"{"question":"q","workspaces":["a.b"]}"#,
|
||||
"a workspace name has 1 to 512 letters, digits, _ or -; use own, untrusted, fleet or \
|
||||
another agent's name",
|
||||
),
|
||||
] {
|
||||
let error = parse(ToolName::AskMemory, json).unwrap_err();
|
||||
assert_eq!(error, ArgsError::Shape(sentence.to_string()), "{json}");
|
||||
assert_eq!(
|
||||
error.detail(),
|
||||
format!("invalid argument shape: {sentence}")
|
||||
);
|
||||
}
|
||||
for json in [
|
||||
r#"{"question":"q","reasoning":"low"}"#,
|
||||
r#"{"workspaces":["own"]}"#,
|
||||
"not json",
|
||||
] {
|
||||
let error = parse(ToolName::AskMemory, json).unwrap_err();
|
||||
assert!(matches!(error, ArgsError::Shape(_)), "{json}: {error:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The built-in grant allows `ask_memory` in every session, `secret` and untrusted ones too: a
|
||||
/// question is not written to Honcho (M5c spec, section 8). Its results are labelled `private`
|
||||
/// and trusted; `memoryd`'s own labels can only make them stricter.
|
||||
#[test]
|
||||
fn the_built_in_grant_allows_it_in_every_session() {
|
||||
assert_eq!(
|
||||
ASK_MEMORY,
|
||||
"tool = \"ask_memory\"\nmode = \"auto\"\nmax_taint = \"secret\"\nresult_class = \
|
||||
\"private\"\nuntrusted = false\n"
|
||||
);
|
||||
let grants = set(vec![]);
|
||||
let untrusted = SessionState {
|
||||
taint: DataClass::Secret,
|
||||
untrusted: true,
|
||||
};
|
||||
for state in [private(), secret(), untrusted] {
|
||||
let d = allowed(decide(
|
||||
build::request("ask_memory", QUESTION),
|
||||
&grants,
|
||||
state,
|
||||
now(),
|
||||
));
|
||||
assert_eq!(d.grant(), "builtin-ask-memory");
|
||||
assert_eq!(d.grant_sha256(), sha256(ASK_MEMORY.as_bytes()).unwrap());
|
||||
assert_eq!(
|
||||
d.label(),
|
||||
Label {
|
||||
class: DataClass::Private,
|
||||
untrusted: false
|
||||
}
|
||||
);
|
||||
}
|
||||
let ids: Vec<&str> = grants.matchable().map(|g| g.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
["builtin-memory", "builtin-recall", "builtin-ask-memory"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grant_file_cannot_name_it() {
|
||||
assert_eq!(
|
||||
BUILT_IN,
|
||||
"the policy of recall, memory and ask_memory is built in; remove this grant"
|
||||
);
|
||||
let dir = TempDir::new("ask-grants");
|
||||
std::fs::write(
|
||||
dir.path().join("mine.toml"),
|
||||
"tool = \"ask_memory\"\nmode = \"deny\"\nmax_taint = \"secret\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let problems = load(dir.path()).unwrap_err();
|
||||
let got: Vec<(String, String)> = problems.into_iter().map(|p| (p.file, p.problem)).collect();
|
||||
assert_eq!(got, [("mine.toml".to_string(), BUILT_IN.to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_goes_to_memoryd_with_its_canonical_arguments() {
|
||||
let dir = TempDir::new("ask-route");
|
||||
let socket = dir.path().join("memory.sock");
|
||||
let seen = fake_memoryd(&socket, answer(DataClass::Private, false));
|
||||
let other = Recording::answering("container");
|
||||
let asked = r#"{"workspaces":["own","fleet"],"question":"Who wrote it?"}"#;
|
||||
let got = run(
|
||||
allowed(decide(
|
||||
build::request("ask_memory", asked),
|
||||
&set(vec![]),
|
||||
secret(),
|
||||
now(),
|
||||
)),
|
||||
&routed(&socket, &other),
|
||||
);
|
||||
assert_eq!(got, answer(DataClass::Private, false));
|
||||
let call = seen.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert_eq!(call.request.tool, "ask_memory");
|
||||
assert_eq!(
|
||||
call.request.arguments,
|
||||
r#"{"question":"Who wrote it?","workspaces":["own","fleet"]}"#
|
||||
);
|
||||
assert_eq!((call.taint, call.untrusted), (DataClass::Secret, false));
|
||||
assert_eq!(other.count(), 0, "nothing reached the other runtime");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_container_runtime_never_runs_it() {
|
||||
let _s = serial();
|
||||
let fake = Fake::new("ask", "exit 0");
|
||||
let log = Lines::default();
|
||||
let podman = Podman::new(fake.runner(""), fake.dir.join("egress"), log.sink());
|
||||
let got = run(
|
||||
allowed(decide(
|
||||
build::request("ask_memory", QUESTION),
|
||||
&set(vec![]),
|
||||
private(),
|
||||
now(),
|
||||
)),
|
||||
&podman,
|
||||
);
|
||||
assert_eq!(
|
||||
got,
|
||||
ToolResponse::Failed {
|
||||
message: IN_MEMORYD.to_string()
|
||||
}
|
||||
);
|
||||
assert!(fake.calls().is_empty(), "podman was never called");
|
||||
}
|
||||
|
||||
/// One `ask_memory` call through `broker::handle`, whose runtime sends it to a fake `memoryd`
|
||||
/// answering `reply`; the session's state afterwards.
|
||||
fn state_after(tag: &str, reply: ToolResponse) -> SessionState {
|
||||
let rig = Rig::new(tag);
|
||||
let socket = rig.dir.path().join("memory.sock");
|
||||
let _seen = fake_memoryd(&socket, reply);
|
||||
let other = Recording::answering("unused");
|
||||
let broker = Arc::new(Broker::new(
|
||||
rig.cfg.clone(),
|
||||
rig.ledger(),
|
||||
Box::new(routed(&socket, &other)),
|
||||
rig.lines.sink(),
|
||||
));
|
||||
let frames = client::call(&broker, rig::request("s1", 1, "ask_memory", QUESTION));
|
||||
assert!(
|
||||
matches!(
|
||||
frames.as_slice(),
|
||||
[Envelope {
|
||||
msg: Message::ToolResponse(ToolResponse::Result { .. }),
|
||||
..
|
||||
}]
|
||||
),
|
||||
"{frames:?}"
|
||||
);
|
||||
rig.state().read(&SessionId::new("s1").unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// T58: an answer from a workspace that is not trusted makes a clean session untrusted, as a
|
||||
/// `memory-tainted` recall hit does.
|
||||
#[test]
|
||||
fn an_untrusted_answer_makes_the_session_untrusted() {
|
||||
assert_eq!(
|
||||
state_after("ask-untrusted", answer(DataClass::Private, true)),
|
||||
SessionState {
|
||||
taint: DataClass::Private,
|
||||
untrusted: true
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// T58: an answer labelled `secret` raises the session to `secret`.
|
||||
#[test]
|
||||
fn a_secret_answer_raises_the_session_to_secret() {
|
||||
assert_eq!(
|
||||
state_after("ask-secret", answer(DataClass::Secret, false)),
|
||||
SessionState {
|
||||
taint: DataClass::Secret,
|
||||
untrusted: false
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// The grant's label is the floor: a `public` answer still leaves the session `private`.
|
||||
#[test]
|
||||
fn a_public_answer_leaves_the_session_private() {
|
||||
assert_eq!(
|
||||
state_after("ask-public", answer(DataClass::Public, false)),
|
||||
SessionState {
|
||||
taint: DataClass::Private,
|
||||
untrusted: false
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -95,7 +95,7 @@ fn an_empty_directory_is_a_valid_empty_set() {
|
||||
let builtin: Vec<&str> = set.matchable().map(|g| g.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
builtin,
|
||||
["builtin-memory", "builtin-recall"],
|
||||
["builtin-memory", "builtin-recall", "builtin-ask-memory"],
|
||||
"only the built-ins"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,5 +138,5 @@ fn grant_files_cannot_name_the_memory_tools_or_take_a_built_in_id() {
|
||||
1,
|
||||
"the built-ins are not listed as the owner's"
|
||||
);
|
||||
assert_eq!(with_shell.matchable().count(), 3);
|
||||
assert_eq!(with_shell.matchable().count(), 4);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user