loopd: the memory block from memoryd, recall and memory as core tools
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -31,21 +31,22 @@ fn assembles_system_prompt_and_core_schemas() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_memory_is_appended_when_present_and_only_then() {
|
||||
fn the_memory_block_is_appended_when_present_and_only_then() {
|
||||
let home = Home::new();
|
||||
let cfg = home.config(Path::new("/tmp/unused.sock"));
|
||||
home.write("memory/core.md", "The owner likes cork.\n\n");
|
||||
let b = Baseline::assemble(&cfg, &Registry::m2b()).unwrap();
|
||||
let block = "# soul.md\nYou are kind.\n\n# memory.md (0 of 2,200 characters)\n(empty)\n";
|
||||
let b = Baseline::assemble_with(&cfg, &Registry::m2b(), block).unwrap();
|
||||
assert_eq!(
|
||||
b.system,
|
||||
"You are Boxmaker, a test agent.\n\nThe owner likes cork."
|
||||
"You are Boxmaker, a test agent.\n\n# soul.md\nYou are kind.\n\n# memory.md (0 of 2,200 characters)\n(empty)"
|
||||
);
|
||||
home.write("memory/core.md", " \n");
|
||||
let b = Baseline::assemble(&cfg, &Registry::m2b()).unwrap();
|
||||
home.write("memory/core.md", "An old core file is ignored.\n");
|
||||
let b = Baseline::assemble_with(&cfg, &Registry::m2b(), " \n").unwrap();
|
||||
assert_eq!(
|
||||
b.system, "You are Boxmaker, a test agent.",
|
||||
"an empty core file adds nothing"
|
||||
"an empty block adds nothing, and core.md is no longer read"
|
||||
);
|
||||
assert_eq!(b, Baseline::assemble(&cfg, &Registry::m2b()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -232,28 +233,3 @@ fn replay_of_a_prefix_is_a_prefix() {
|
||||
assert_eq!(whole[..part.len()], part[..], "prefix of {n} records");
|
||||
}
|
||||
}
|
||||
|
||||
/// A core memory file that exists but cannot be read is an error, not silently absent: the owner
|
||||
/// would otherwise get a session without the memory they curated, and no sign of it.
|
||||
#[test]
|
||||
fn an_unreadable_core_memory_file_is_an_error() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if running_as_root() {
|
||||
return; // root can read anything; the check is meaningless there
|
||||
}
|
||||
let home = Home::new();
|
||||
let cfg = home.config(Path::new("/tmp/unused.sock"));
|
||||
home.write("memory/core.md", "secret memory\n");
|
||||
let core = home.dir.join("memory/core.md");
|
||||
std::fs::set_permissions(&core, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
let result = Baseline::assemble(&cfg, &Registry::m2b());
|
||||
std::fs::set_permissions(&core, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
let e = result.expect_err("an unreadable core.md must not be ignored");
|
||||
assert!(e.to_string().contains("core.md"), "{e}");
|
||||
}
|
||||
|
||||
fn running_as_root() -> bool {
|
||||
std::fs::read_to_string("/proc/self/status")
|
||||
.map(|s| s.lines().any(|l| l.starts_with("Uid:\t0\t")))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ mod support;
|
||||
|
||||
use loopd::channel::{Context, serve};
|
||||
use loopd::llama::Client;
|
||||
use loopd::memory::NoMemory;
|
||||
use loopd::tools::Registry;
|
||||
use proto::{
|
||||
Envelope, ErrorCode, Message, PROTOCOL_VERSION, SessionId, Turn, TurnEvent, read_frame,
|
||||
@@ -38,6 +39,7 @@ fn start(replies: Vec<proto::ToolResponse>) -> Loopd {
|
||||
Client::new(cfg),
|
||||
Box::new(ScriptedPort::new(replies)),
|
||||
Registry::m2b(),
|
||||
Box::new(NoMemory),
|
||||
));
|
||||
thread::spawn(move || serve(listener, ctx));
|
||||
Loopd {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! `loopd` and memory: the memory block in a new session's baseline and not in a running one's,
|
||||
//! no session without it, `memoryd` told after a finished turn, and the two core memory tools
|
||||
//! (M5a spec, section 9). Do not edit.
|
||||
|
||||
mod support;
|
||||
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use loopd::channel::{Context, serve};
|
||||
use loopd::llama::Client;
|
||||
use loopd::memory::{MemorySource, UNAVAILABLE};
|
||||
use loopd::tools::Registry;
|
||||
use proto::{
|
||||
Envelope, ErrorCode, Message, PROTOCOL_VERSION, SessionId, Turn, read_frame, write_frame,
|
||||
};
|
||||
use support::{FakeServer, Home, Reply, ScriptedPort};
|
||||
|
||||
const CHAT: &str = "/v1/chat/completions";
|
||||
|
||||
/// A `memoryd` stand-in: the block it serves, and the sessions it was told about.
|
||||
struct Recorder {
|
||||
block: Mutex<Result<String, String>>,
|
||||
updated: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Lets the test keep its `Arc<Recorder>` while the context owns a `Box<dyn MemorySource>`.
|
||||
struct Shared(Arc<Recorder>);
|
||||
|
||||
impl MemorySource for Shared {
|
||||
fn block(&self) -> Result<String, String> {
|
||||
self.0.block.lock().unwrap().clone()
|
||||
}
|
||||
fn updated(&self, session: &SessionId) -> Result<(), String> {
|
||||
self.0
|
||||
.updated
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(session.as_str().to_string());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct Loopd {
|
||||
_home: Home,
|
||||
server: FakeServer,
|
||||
socket: PathBuf,
|
||||
memory: Arc<Recorder>,
|
||||
}
|
||||
|
||||
fn start(block: Result<&str, &str>) -> Loopd {
|
||||
let home = Home::new();
|
||||
let server = FakeServer::start();
|
||||
let cfg = home.config(&server.socket);
|
||||
let socket = cfg.channel_socket();
|
||||
let listener = UnixListener::bind(&socket).unwrap();
|
||||
let memory = Arc::new(Recorder {
|
||||
block: Mutex::new(block.map(str::to_string).map_err(str::to_string)),
|
||||
updated: Mutex::new(Vec::new()),
|
||||
});
|
||||
let ctx = Arc::new(Context::new(
|
||||
cfg.clone(),
|
||||
Client::new(cfg),
|
||||
Box::new(ScriptedPort::new(vec![])),
|
||||
Registry::m2b(),
|
||||
Box::new(Shared(Arc::clone(&memory))),
|
||||
));
|
||||
std::thread::spawn(move || serve(listener, ctx));
|
||||
Loopd {
|
||||
_home: home,
|
||||
server,
|
||||
socket,
|
||||
memory,
|
||||
}
|
||||
}
|
||||
|
||||
/// One turn; the last frame.
|
||||
fn turn(l: &Loopd, session: &str, resume: bool) -> Message {
|
||||
let mut stream = UnixStream::connect(&l.socket).unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(10)))
|
||||
.unwrap();
|
||||
let msg = Message::Turn(Turn {
|
||||
session: SessionId::new(session).unwrap(),
|
||||
content: "hello".to_string(),
|
||||
resume,
|
||||
});
|
||||
let env = Envelope {
|
||||
v: PROTOCOL_VERSION,
|
||||
id: 7,
|
||||
r#final: true,
|
||||
msg,
|
||||
};
|
||||
write_frame(&mut stream, &env).unwrap();
|
||||
loop {
|
||||
let frame = read_frame(&mut stream).unwrap();
|
||||
if frame.r#final {
|
||||
return frame.msg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn system_prompt(l: &Loopd, n: usize) -> String {
|
||||
let requests = l.server.requests_to(CHAT);
|
||||
requests[n].json()["messages"][0]["content"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_session_starts_with_the_block_and_keeps_it() {
|
||||
let l = start(Ok("# soul.md\nYou are kind."));
|
||||
l.server
|
||||
.route(CHAT, vec![Reply::fixture("plain"), Reply::fixture("plain")]);
|
||||
assert!(matches!(turn(&l, "a", false), Message::TurnDone(_)));
|
||||
assert_eq!(
|
||||
system_prompt(&l, 0),
|
||||
"You are Boxmaker, a test agent.\n\n# soul.md\nYou are kind."
|
||||
);
|
||||
*l.memory.block.lock().unwrap() = Ok("# soul.md\nYou changed.".to_string());
|
||||
assert!(matches!(turn(&l, "a", true), Message::TurnDone(_)));
|
||||
assert_eq!(
|
||||
system_prompt(&l, 1),
|
||||
"You are Boxmaker, a test agent.\n\n# soul.md\nYou are kind.",
|
||||
"a running session keeps the bytes it started with"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_session_starts_without_the_block() {
|
||||
let l = start(Err(
|
||||
"memoryd at /x: refused; see docs/runbook.md#memory-unavailable",
|
||||
));
|
||||
match turn(&l, "b", false) {
|
||||
Message::Error(e) => {
|
||||
assert_eq!(e.code, ErrorCode::Internal);
|
||||
assert!(e.detail.ends_with(UNAVAILABLE), "{}", e.detail);
|
||||
}
|
||||
other => panic!("started without memory: {other:?}"),
|
||||
}
|
||||
assert!(l.server.requests_to(CHAT).is_empty(), "no request was made");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memoryd_is_told_after_a_finished_turn_only() {
|
||||
let l = start(Ok(""));
|
||||
l.server.route(
|
||||
CHAT,
|
||||
vec![Reply::fixture("plain"), Reply::fixture("bad_request")],
|
||||
);
|
||||
turn(&l, "c", false);
|
||||
assert!(
|
||||
matches!(turn(&l, "d", false), Message::Error(_)),
|
||||
"a turn that ran and failed"
|
||||
);
|
||||
assert!(matches!(
|
||||
turn(&l, "nobody-made-this", true),
|
||||
Message::Error(_)
|
||||
));
|
||||
assert_eq!(*l.memory.updated.lock().unwrap(), ["c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recall_and_memory_are_core_tools_of_the_m5a_registry() {
|
||||
let names: Vec<String> = Registry::m5a()
|
||||
.core_schemas()
|
||||
.into_iter()
|
||||
.map(|s| s.name)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
["clock", "recall", "memory", "find_tool", "call_tool"]
|
||||
);
|
||||
let m3a: Vec<String> = Registry::m3a()
|
||||
.core_schemas()
|
||||
.into_iter()
|
||||
.map(|s| s.name)
|
||||
.collect();
|
||||
assert_eq!(m3a, ["clock", "find_tool", "call_tool"], "m3a is unchanged");
|
||||
assert!(Registry::m5a().get("read_file").is_some());
|
||||
}
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
mod support;
|
||||
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::Path;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
use loopd::baseline::Baseline;
|
||||
use loopd::broker_port::{not_configured_line, unavailable_line};
|
||||
use loopd::memory::{MemoryPort, MemorySource};
|
||||
use loopd::session::{Session, SessionError};
|
||||
use loopd::tools::Registry;
|
||||
use proto::SessionId;
|
||||
@@ -64,31 +64,37 @@ fn only_the_torn_error_points_at_the_damaged_log_entry() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_core_memory_names_its_entry_and_a_missing_system_prompt_does_not() {
|
||||
fn memoryd_unreachable_or_refusing_names_the_memory_entry() {
|
||||
let home = Home::new();
|
||||
let cfg = home.config(Path::new("/tmp/unused.sock"));
|
||||
home.write("memory/core.md", "memory\n");
|
||||
let core = home.dir.join("memory/core.md");
|
||||
if !running_as_root() {
|
||||
std::fs::set_permissions(&core, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
let result = Baseline::assemble(&cfg, &Registry::m3a());
|
||||
std::fs::set_permissions(&core, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
let message = result.expect_err("unreadable core.md").to_string();
|
||||
assert!(message.contains("core.md"), "{message}");
|
||||
ends_with_pointer(&message, "see docs/runbook.md#core-memory-unreadable");
|
||||
}
|
||||
// The system prompt is a different file with a different remedy: no pointer to this entry.
|
||||
std::fs::remove_file(home.dir.join("system.md")).unwrap();
|
||||
let message = Baseline::assemble(&cfg, &Registry::m3a())
|
||||
.expect_err("no system.md")
|
||||
.to_string();
|
||||
assert!(!message.contains("core-memory-unreadable"), "{message}");
|
||||
}
|
||||
|
||||
fn running_as_root() -> bool {
|
||||
std::fs::read_to_string("/proc/self/status")
|
||||
.map(|s| s.lines().any(|l| l.starts_with("Uid:\t0\t")))
|
||||
.unwrap_or(false)
|
||||
let port = MemoryPort::new(
|
||||
home.dir.join("run/nobody/memory.sock"),
|
||||
Duration::from_secs(1),
|
||||
);
|
||||
let message = port.block().expect_err("nobody is listening");
|
||||
ends_with_pointer(&message, "see docs/runbook.md#memory-unavailable");
|
||||
// A refusal from memoryd itself (a file over its limit) ends with the same entry.
|
||||
let socket = home.dir.join("run/loop-memory/memory.sock");
|
||||
std::fs::create_dir_all(socket.parent().unwrap()).unwrap();
|
||||
let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
|
||||
std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let env = proto::read_frame(&mut stream).unwrap();
|
||||
let reply = proto::Envelope {
|
||||
v: 1,
|
||||
id: env.id,
|
||||
r#final: true,
|
||||
msg: proto::Message::Error(proto::WireError {
|
||||
code: proto::ErrorCode::Internal,
|
||||
detail: "memory/user.md: 1,376 characters; its limit is 1,375".to_string(),
|
||||
}),
|
||||
};
|
||||
proto::write_frame(&mut stream, &reply).unwrap();
|
||||
});
|
||||
let message = MemoryPort::new(socket, Duration::from_secs(5))
|
||||
.block()
|
||||
.expect_err("memoryd refused");
|
||||
assert!(message.contains("user.md: 1,376 characters"), "{message}");
|
||||
ends_with_pointer(&message, "see docs/runbook.md#memory-unavailable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user