diff --git a/plans/0011-session-state.md b/plans/0011-session-state.md index fbb153c..2867eca 100644 --- a/plans/0011-session-state.md +++ b/plans/0011-session-state.md @@ -187,7 +187,7 @@ the engine can re-scan it on restart. names - [x] 2. Auto-establishment — create stub entity files when a wikilink does not resolve -- [ ] 3. Context injection — push referenced entities' notes into the +- [x] 3. Context injection — push referenced entities' notes into the next turn's prompt - [x] 4. Wikilink rendering — format as bold in the terminal, preserve raw syntax in the transcript diff --git a/src/dm/dm_entity_notes_tests.rs b/src/dm/dm_entity_notes_tests.rs new file mode 100644 index 0000000..b846b4f --- /dev/null +++ b/src/dm/dm_entity_notes_tests.rs @@ -0,0 +1,158 @@ +//! Tests for entity note injection: the notes of the entities the +//! previous turn named, appended to the next turn's system prompt. + +use super::tests::{dm_for, dm_with_campaign, fake_server, sent_messages, sse_response}; + +use tempfile::TempDir; + +/// The preamble's opening words, distinctive enough to prove the whole +/// entity notes section is present or absent. +const PREAMBLE_MARKER: &str = "the session's notes for the entities"; + +/// The system prompt of the next request the fake server captured. +fn next_system_prompt( + requests: &std::sync::mpsc::Receiver, +) -> String { + sent_messages(&requests.recv().unwrap())[0]["content"] + .as_str() + .unwrap() + .to_string() +} + +#[test] +fn a_second_turns_prompt_carries_the_note_of_an_entity_the_first_turn_named() { + let world = TempDir::new().unwrap(); + let first_reply = "data: {\"choices\":[{\"delta\":{\"content\":\"You meet [[Vera Blackwater]] at the bar.\"},\"finish_reason\":null}]}\n\n\ + data: [DONE]\n\n"; + let (url, requests, server) = fake_server(vec![ + sse_response(first_reply), + sse_response("data: [DONE]\n\n"), + ]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I enter the tavern.").unwrap(); + dm.turn("I greet her.").unwrap(); + + server.join().unwrap(); + requests.recv().unwrap(); + let prompt = next_system_prompt(&requests); + + assert!(prompt.contains("### Vera Blackwater")); + assert!(prompt.contains("First seen")); + assert!(prompt.contains(PREAMBLE_MARKER)); +} + +#[test] +fn two_named_entities_notes_both_reach_the_next_prompt() { + let world = TempDir::new().unwrap(); + let first_reply = "data: {\"choices\":[{\"delta\":{\"content\":\"[[Vera Blackwater]] nods to [[people/Joseph Black]].\"},\"finish_reason\":null}]}\n\n\ + data: [DONE]\n\n"; + let (url, requests, server) = fake_server(vec![ + sse_response(first_reply), + sse_response("data: [DONE]\n\n"), + ]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I enter the tavern.").unwrap(); + dm.turn("I watch them.").unwrap(); + + server.join().unwrap(); + requests.recv().unwrap(); + let prompt = next_system_prompt(&requests); + + assert!(prompt.contains("### Vera Blackwater")); + assert!(prompt.contains("### people/Joseph Black")); +} + +#[test] +fn a_note_edited_on_disk_between_turns_reaches_the_next_prompt() { + let world = TempDir::new().unwrap(); + let first_reply = "data: {\"choices\":[{\"delta\":{\"content\":\"You meet [[Vera Blackwater]] at the bar.\"},\"finish_reason\":null}]}\n\n\ + data: [DONE]\n\n"; + let (url, requests, server) = fake_server(vec![ + sse_response(first_reply), + sse_response("data: [DONE]\n\n"), + ]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I enter the tavern.").unwrap(); + let stub = world.path().join("entities/Vera Blackwater.md"); + std::fs::write( + &stub, + "---\nkind: person\naliases: []\n---\n\nVera runs the tavern now.\n", + ) + .unwrap(); + dm.turn("I greet her.").unwrap(); + + server.join().unwrap(); + requests.recv().unwrap(); + let prompt = next_system_prompt(&requests); + + assert!(prompt.contains("Vera runs the tavern now.")); + assert!(!prompt.contains("First seen")); +} + +#[test] +fn an_entity_whose_note_file_is_gone_injects_nothing() { + let world = TempDir::new().unwrap(); + let first_reply = "data: {\"choices\":[{\"delta\":{\"content\":\"You meet [[Vera Blackwater]] at the bar.\"},\"finish_reason\":null}]}\n\n\ + data: [DONE]\n\n"; + let (url, requests, server) = fake_server(vec![ + sse_response(first_reply), + sse_response("data: [DONE]\n\n"), + ]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I enter the tavern.").unwrap(); + std::fs::remove_file(world.path().join("entities/Vera Blackwater.md")).unwrap(); + dm.turn("I greet her.").unwrap(); + + server.join().unwrap(); + requests.recv().unwrap(); + let prompt = next_system_prompt(&requests); + + assert!(!prompt.contains("### Vera Blackwater")); + assert!(!prompt.contains(PREAMBLE_MARKER)); +} + +#[test] +fn a_dm_with_no_campaign_injects_nothing_and_still_turns() { + let first_reply = "data: {\"choices\":[{\"delta\":{\"content\":\"You meet [[Vera Blackwater]] at the bar.\"},\"finish_reason\":null}]}\n\n\ + data: [DONE]\n\n"; + let (url, requests, server) = fake_server(vec![ + sse_response(first_reply), + sse_response("data: [DONE]\n\n"), + ]); + let mut dm = dm_for(url); + + dm.turn("I enter the tavern.").unwrap(); + dm.turn("I greet her.").unwrap(); + + server.join().unwrap(); + requests.recv().unwrap(); + let prompt = next_system_prompt(&requests); + + assert!(!prompt.contains("### Vera Blackwater")); + assert!(!prompt.contains(PREAMBLE_MARKER)); +} + +#[test] +fn a_turn_that_names_nothing_injects_no_preamble() { + let world = TempDir::new().unwrap(); + let first_reply = "data: {\"choices\":[{\"delta\":{\"content\":\"You walk down the road.\"},\"finish_reason\":null}]}\n\n\ + data: [DONE]\n\n"; + let (url, requests, server) = fake_server(vec![ + sse_response(first_reply), + sse_response("data: [DONE]\n\n"), + ]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I walk.").unwrap(); + dm.turn("I keep walking.").unwrap(); + + server.join().unwrap(); + requests.recv().unwrap(); + let prompt = next_system_prompt(&requests); + + assert!(!prompt.contains(PREAMBLE_MARKER)); +} diff --git a/src/dm/dm_tests.rs b/src/dm/dm_tests.rs index 6abc8a8..7fae866 100644 --- a/src/dm/dm_tests.rs +++ b/src/dm/dm_tests.rs @@ -17,7 +17,7 @@ use crate::chat::testing::http_response; // rather than keeping its own copy. pub(super) use crate::chat::testing::{CapturedRequest, fake_server, sse_response}; -fn dm_for(api_base: String) -> Dm { +pub(super) fn dm_for(api_base: String) -> Dm { Dm::new( Config { api_base, @@ -33,7 +33,7 @@ fn dm_for(api_base: String) -> Dm { /// A `Dm` whose toolbox is the full default: dice, knowledge, and the /// campaign tools, backed by a fresh campaign in `world`. -fn dm_with_campaign(api_base: String, world: &TempDir) -> Dm { +pub(super) fn dm_with_campaign(api_base: String, world: &TempDir) -> Dm { Dm::new( Config { api_base, diff --git a/src/dm/entity-notes.md b/src/dm/entity-notes.md new file mode 100644 index 0000000..af471ae --- /dev/null +++ b/src/dm/entity-notes.md @@ -0,0 +1,3 @@ +## Entity notes + +These are the session's notes for the entities currently in play. A note may trail the conversation: it reflects what was known the last time it was written, not necessarily what just happened. When the conversation and a note disagree, the conversation wins. diff --git a/src/dm/mod.rs b/src/dm/mod.rs index bd20c6d..ee7800f 100644 --- a/src/dm/mod.rs +++ b/src/dm/mod.rs @@ -2,7 +2,7 @@ //! round loop that runs one player turn. use std::fmt; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use rand::rngs::StdRng; @@ -14,7 +14,7 @@ use crate::campaign::Campaign; use crate::chat::{ChatError, Client, Message, Role, StreamItem}; use crate::config::Config; use crate::context::ContextStack; -use crate::entities::{ScanState, Scanner, seed_resolver}; +use crate::entities::{ScanState, Scanner, note_body, seed_resolver}; use crate::knowledge::Mount; use tools::Tool; use tools::Toolbox; @@ -33,6 +33,10 @@ pub mod tools; /// return next turn. const TOOLS_WITHHELD: &str = include_str!("tools-withheld.md"); +/// The preamble ahead of the entity notes appended to the system prompt: +/// the notes may trail the conversation, and the conversation wins. +const ENTITY_NOTES_PREAMBLE: &str = include_str!("entity-notes.md"); + /// How many consecutive tool-only rounds, rounds that called tools and /// narrated nothing, are allowed before the tools are withheld for one /// round. @@ -126,6 +130,10 @@ pub struct Dm { /// on [`Self::bus`]. The `Dm` reads the completed set here to build /// the next turn's prompt. pub scan: Arc>, + /// The world root notes live under, the same one [`Self::bus`]'s + /// scanner writes stubs to. `None` when there is no campaign, in + /// which case a turn's prompt carries no entity notes at all. + entity_root: Option, } impl Dm { @@ -211,7 +219,10 @@ impl Dm { seed_resolver(root, &mut scan.lock().unwrap()); } let mut bus = TurnBus::new(); - bus.add(Box::new(Scanner::new(Arc::clone(&scan), entity_root))); + bus.add(Box::new(Scanner::new( + Arc::clone(&scan), + entity_root.clone(), + ))); if let Some(campaign) = campaign.clone() { bus.add(Box::new(CampaignListener::new(Some(campaign)))); } @@ -223,6 +234,7 @@ impl Dm { history, bus, scan, + entity_root, }) } @@ -287,6 +299,12 @@ impl Dm { // edit to a fragment reaches this turn. The context entries come // from the mount, which stays as it was scanned at startup. let prompt = self.context.prompt().map_err(TurnError::Context)?; + // The entities the previous turn referenced carry their notes into + // this turn's prompt; the scan lock is held only long enough to + // read the completed set. + let scan = self.scan.lock().unwrap(); + let prompt = append_entity_notes(prompt, self.entity_root.as_deref(), &scan); + drop(scan); self.history[0] = system_message(&prompt); let user_message = Message { @@ -391,6 +409,39 @@ impl Dm { } } +/// Appends the notes of `scan`'s completed entities to `prompt`, so the +/// next turn keeps straight whatever the DM named last turn. Each note +/// is read fresh from `root`, and an entity with no note, or an empty +/// one, contributes nothing: it was established with no world, or it has +/// none to contribute. Nothing is appended, not even +/// [`ENTITY_NOTES_PREAMBLE`], when `root` is `None` or no completed +/// entity has a note. +fn append_entity_notes(prompt: String, root: Option<&Path>, scan: &ScanState) -> String { + let Some(root) = root else { + return prompt; + }; + let mut notes = String::new(); + for entity in scan.completed() { + let Some(body) = note_body(root, &entity.address) else { + continue; + }; + if !notes.is_empty() { + notes.push_str("\n\n"); + } + notes.push_str("### "); + notes.push_str(&entity.address); + notes.push('\n'); + notes.push_str(&body); + } + if notes.is_empty() { + return prompt; + } + format!( + "{prompt}\n\n---\n\n{}\n\n{notes}", + ENTITY_NOTES_PREAMBLE.trim() + ) +} + /// A `Role::System` message with `content`. fn system_message(content: &str) -> Message { Message { @@ -428,3 +479,7 @@ mod tests; #[cfg(test)] #[path = "dm_tool_round_tests.rs"] mod tool_round_tests; + +#[cfg(test)] +#[path = "dm_entity_notes_tests.rs"] +mod entity_notes_tests; diff --git a/src/entities/entities_tests.rs b/src/entities/entities_tests.rs index 1455a55..f348622 100644 --- a/src/entities/entities_tests.rs +++ b/src/entities/entities_tests.rs @@ -3,7 +3,7 @@ use std::sync::{Arc, Mutex}; -use super::{Resolution, ScanState, Scanner, extract_wikilinks, stub_body}; +use super::{Resolution, ScanState, Scanner, extract_wikilinks, note_body, stub_body}; use crate::bus::{Flow, Listener}; use crate::campaign::START; use tempfile::TempDir; @@ -407,3 +407,53 @@ fn a_cancelled_turn_writes_no_stub() { Resolution::Found("Vera Blackwater".to_string()) ); } + +// --------------------------------------------------------------------------- +// note_body +// --------------------------------------------------------------------------- + +#[test] +fn note_body_strips_frontmatter_and_trims_the_body() { + let world = TempDir::new().unwrap(); + let file = world.path().join("entities/Vera Blackwater.md"); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + std::fs::write( + &file, + "---\nkind: person\naliases: []\n---\n\n Vera keeps the Rusty Anchor. \n", + ) + .unwrap(); + + assert_eq!( + note_body(world.path(), "Vera Blackwater"), + Some("Vera keeps the Rusty Anchor.".to_string()) + ); +} + +#[test] +fn note_body_reads_a_file_with_no_frontmatter_whole() { + let world = TempDir::new().unwrap(); + let file = world.path().join("entities/Vera Blackwater.md"); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + std::fs::write(&file, "Vera keeps the Rusty Anchor.\n").unwrap(); + + assert_eq!( + note_body(world.path(), "Vera Blackwater"), + Some("Vera keeps the Rusty Anchor.".to_string()) + ); +} + +#[test] +fn note_body_is_none_for_a_missing_file() { + let world = TempDir::new().unwrap(); + assert_eq!(note_body(world.path(), "Vera Blackwater"), None); +} + +#[test] +fn note_body_is_none_for_a_note_with_no_body() { + let world = TempDir::new().unwrap(); + let file = world.path().join("entities/Vera Blackwater.md"); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + std::fs::write(&file, "---\nkind: person\naliases: []\n---\n\n").unwrap(); + + assert_eq!(note_body(world.path(), "Vera Blackwater"), None); +} diff --git a/src/entities/mod.rs b/src/entities/mod.rs index 3f7ee12..63f0c39 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -16,8 +16,8 @@ //! home for a bare name is the `entities/` bucket. [`seed_resolver`] //! walks the world root at startup and registers every note already on //! disk, so a bare name cannot re-establish an entity that already -//! exists. Reading an entity's note body into the next prompt is a -//! later phase. +//! exists. [`note_body`] reads an entity's note fresh from disk, for the +//! `Dm` to inject into the next turn's prompt. use std::collections::{BTreeMap, BTreeSet}; use std::fs; @@ -29,6 +29,7 @@ use serde_yaml_ng::Value; use crate::bus::{Flow, Listener}; use crate::campaign::{CampaignLog, GameTime, START}; +use crate::knowledge::frontmatter::split_frontmatter; use crate::knowledge::read_frontmatter; /// The width of the narration window a stub's seed line carries around @@ -275,6 +276,26 @@ fn stub_path(root: &Path, address: &str) -> PathBuf { root.join(relative).with_extension("md") } +/// The address's note body, read fresh from disk under `root`, with its +/// frontmatter stripped and its whitespace trimmed. `None` when the note +/// file does not exist or its body is empty: an entity established with +/// no world has no note, and its name already lives in the conversation +/// history. A file with no frontmatter delimiter is read whole, since a +/// hand-written note may skip the frontmatter entirely. +pub(crate) fn note_body(root: &Path, address: &str) -> Option { + let contents = fs::read_to_string(stub_path(root, address)).ok()?; + let body = match split_frontmatter(&contents) { + Ok((_frontmatter, body)) => body, + Err(_) => contents, + }; + let body = body.trim(); + if body.is_empty() { + None + } else { + Some(body.to_string()) + } +} + /// The body of a fresh entity stub: frontmatter for the machines, and the /// narration that summoned it as a seed line the Archivist rewrites into /// a real briefing.