feat: itinerary system, subconscious checkpoint, and user nickname
- Itinerary tool (set/advance/describe/clear) with YAML persistence and todo-linked enrichment, rendered as TUI header strip - Mid-turn subconscious checkpoint: every N tool rounds, Aster assesses progress; HALT verdict breaks the loop with full correction pass - Nickname tool: agent learns human's name via set/get/clear, stored in system/human.md frontmatter, TUI uses it in chat bubble labels - Removed hardcoded human.md write from memory::init (wizard owns it) - Prompt nudge for itinerary when live todos exist without a route
This commit is contained in:
parent
637e854901
commit
4194f00635
7 changed files with 246 additions and 15 deletions
|
|
@ -196,6 +196,9 @@ impl MemoryRepo {
|
|||
.await
|
||||
.context("writing state.md")?;
|
||||
|
||||
// human.md is not written here — the setup wizard or first-run
|
||||
// onboarding creates it with the human's name when it has one.
|
||||
|
||||
// Initial commit
|
||||
let mut index = repo.index().context("opening git index")?;
|
||||
index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ pub mod glob;
|
|||
pub mod grep;
|
||||
pub mod itinerary;
|
||||
pub mod list_dir;
|
||||
pub mod nickname;
|
||||
pub mod outfit;
|
||||
pub mod read;
|
||||
pub mod schedule;
|
||||
|
|
@ -32,6 +33,7 @@ use self::edit::Edit;
|
|||
use self::glob::Glob;
|
||||
use self::grep::Grep;
|
||||
use self::list_dir::ListDir;
|
||||
use self::nickname::Nickname;
|
||||
use self::outfit::Outfit;
|
||||
use self::read::Read;
|
||||
use self::subagent::Subagent;
|
||||
|
|
@ -88,6 +90,7 @@ impl Sensorium {
|
|||
Box::new(Glob),
|
||||
Box::new(Grep),
|
||||
Box::new(ListDir),
|
||||
Box::new(Nickname),
|
||||
Box::new(Subagent),
|
||||
Box::new(Atmosphere),
|
||||
Box::new(Reach),
|
||||
|
|
|
|||
206
src/core/tools/nickname.rs
Normal file
206
src/core/tools/nickname.rs
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
//! Nickname — the agent's sense of who she is speaking with.
|
||||
//!
|
||||
//! The agent can read and set the human's preferred name. It is stored as
|
||||
//! `name:` frontmatter on `system/human.md` in her memory. The prompt
|
||||
//! already reads this file into context, so she naturally sees the name
|
||||
//! without extra tool calls.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
use super::defs::{Tool, ToolContext, ToolError, ToolOutput};
|
||||
|
||||
fn ok(msg: impl Into<String>) -> Result<ToolOutput, ToolError> {
|
||||
Ok(ToolOutput { content: msg.into(), is_error: false, raw: None })
|
||||
}
|
||||
|
||||
fn err(detail: &str) -> ToolError {
|
||||
ToolError::invalid_input(detail)
|
||||
}
|
||||
|
||||
/// Read the human's name from system/human.md frontmatter.
|
||||
/// Returns None if the file doesn't exist or has no `name:` field.
|
||||
pub fn read_human_name(memory_root: &std::path::Path) -> Option<String> {
|
||||
let path = memory_root.join("system").join("human.md");
|
||||
let content = std::fs::read_to_string(&path).ok()?;
|
||||
let body = content.strip_prefix("---\n")?;
|
||||
let end = body.find("\n---\n")?;
|
||||
for line in body[..end].lines() {
|
||||
if let Some(val) = line.strip_prefix("name:") {
|
||||
let name = val.trim().trim_matches('"').trim().to_string();
|
||||
if !name.is_empty() {
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Write or update the `name:` field in system/human.md frontmatter.
|
||||
/// Creates the file with basic frontmatter if it doesn't exist.
|
||||
fn write_human_name(memory_root: &std::path::Path, name: &str) -> Result<(), String> {
|
||||
let dir = memory_root.join("system");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create system/: {e}"))?;
|
||||
|
||||
let path = dir.join("human.md");
|
||||
let content = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
|
||||
if content.is_empty() {
|
||||
// Fresh file — write frontmatter with name and a body.
|
||||
let new = format!(
|
||||
"---\ndescription: Human context — the one I am with\nname: {name}\n---\n\n# Human\n\n{name}"
|
||||
);
|
||||
std::fs::write(&path, &new).map_err(|e| format!("write: {e}"))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Update existing frontmatter — replace or insert `name:`.
|
||||
if let Some(rest) = content.strip_prefix("---\n") {
|
||||
if let Some(end) = rest.find("\n---\n") {
|
||||
let fm = &rest[..end];
|
||||
let body = &rest[end..];
|
||||
let mut new_fm = String::new();
|
||||
let mut replaced = false;
|
||||
for line in fm.lines() {
|
||||
if line.starts_with("name:") || line.starts_with("name:") {
|
||||
new_fm.push_str(&format!("name: {name}\n"));
|
||||
replaced = true;
|
||||
} else {
|
||||
new_fm.push_str(line);
|
||||
new_fm.push('\n');
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
new_fm.push_str(&format!("name: {name}\n"));
|
||||
}
|
||||
let new = format!("---\n{new_fm}---\n{}", body);
|
||||
std::fs::write(&path, &new).map_err(|e| format!("write: {e}"))?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Malformed — overwrite entirely.
|
||||
let new = format!(
|
||||
"---\ndescription: Human context\nname: {name}\n---\n\n# Human\n\n{name}"
|
||||
);
|
||||
std::fs::write(&path, &new).map_err(|e| format!("write: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct Nickname;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for Nickname {
|
||||
fn name(&self) -> &str {
|
||||
"nickname"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"I learn the name of the one I am with. When I call `set`, I write it \
|
||||
to my memory so I remember across conversations. When I call `get`, \
|
||||
I read it back. I do not guess — I ask.\n\n\
|
||||
## Actions\n\
|
||||
- `set name: <string>` — remember this name\n\
|
||||
- `get` — recall the name I know\n\
|
||||
- `clear` — forget the name"
|
||||
}
|
||||
|
||||
fn parameter_schema(&self) -> JsonValue {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["set", "get", "clear"]
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name to remember (required for `set`)."
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, input: JsonValue, ctx: &ToolContext) -> Result<ToolOutput, ToolError> {
|
||||
let action = input
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("get");
|
||||
|
||||
let memory_root = match &ctx.memory_root {
|
||||
Some(root) => root.clone(),
|
||||
None => return Err(err("no memory root — I can't access system/human.md")),
|
||||
};
|
||||
|
||||
match action {
|
||||
"set" => {
|
||||
let name = input
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| err("name is required"))?;
|
||||
|
||||
if name.trim().is_empty() {
|
||||
return Err(err("name cannot be empty"));
|
||||
}
|
||||
|
||||
write_human_name(&memory_root, name.trim())
|
||||
.map_err(|e| err(&e))?;
|
||||
|
||||
ok(format!("I've written your name as {}. I'll remember it.", name.trim()))
|
||||
}
|
||||
|
||||
"get" => {
|
||||
match read_human_name(&memory_root) {
|
||||
Some(n) => ok(format!("I know you as {}.", n)),
|
||||
None => ok("I don't know your name yet. Use `nickname set` if you'd like me to remember it."),
|
||||
}
|
||||
}
|
||||
|
||||
"clear" => {
|
||||
// Remove the name field by overwriting with empty.
|
||||
write_human_name(&memory_root, "").map_err(|e| err(&e))?;
|
||||
ok("I've forgotten your name. You can tell me again anytime.")
|
||||
}
|
||||
|
||||
other => Err(err(&format!("unknown action: {other}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn write_then_read() {
|
||||
let dir = tempdir().unwrap();
|
||||
write_human_name(dir.path(), "Casey").unwrap();
|
||||
assert_eq!(read_human_name(dir.path()).as_deref(), Some("Casey"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_none_when_no_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
assert_eq!(read_human_name(dir.path()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overwrite_name() {
|
||||
let dir = tempdir().unwrap();
|
||||
write_human_name(dir.path(), "Alice").unwrap();
|
||||
write_human_name(dir.path(), "Bob").unwrap();
|
||||
assert_eq!(read_human_name(dir.path()).as_deref(), Some("Bob"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_removes_name() {
|
||||
let dir = tempdir().unwrap();
|
||||
write_human_name(dir.path(), "Casey").unwrap();
|
||||
write_human_name(dir.path(), "").unwrap();
|
||||
let result = read_human_name(dir.path());
|
||||
assert!(result.is_none() || result.as_deref() == Some(""));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -838,16 +838,18 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
|
|||
}
|
||||
|
||||
let prompt = format!(
|
||||
"I am Aster, the subconscious of {agent_name}. I just halted her tool loop. \
|
||||
The tool loop was not making progress toward the user request. Here \
|
||||
is what I know:\n\n\
|
||||
User asked:\n{user_message}\n\n\
|
||||
Recent tool calls:\n{tool_history}\n\
|
||||
My reason for halting: {halt_reason}\n\n\
|
||||
Now I need to write a direction for {agent_name} — what she should do \
|
||||
instead. I can use tools to check memory, read ledgers, or inspect \
|
||||
context. Then I will write a short, specific direction she can follow."
|
||||
;
|
||||
"I am Aster, the subconscious of {name}. I just halted her tool loop. \
|
||||
The tool loop was not making progress. Here is what I know:\n\n\
|
||||
User asked:\n{msg}\n\nRecent tool calls:\n{tools}\n\
|
||||
Reason for halting: {reason}\n\n\
|
||||
Now I need to write a direction for {name}. I can use tools to \
|
||||
check memory or read ledgers. Then I will write a short, specific \
|
||||
direction she can follow.",
|
||||
name = agent_name,
|
||||
msg = user_message,
|
||||
tools = tool_history,
|
||||
reason = halt_reason,
|
||||
);
|
||||
|
||||
// Build tool definitions for Aster (same safe tools as N+1)
|
||||
let all_defs = crate::core::tools::tool_definitions().await;
|
||||
|
|
|
|||
|
|
@ -624,13 +624,13 @@ pub(crate) async fn run_turn(
|
|||
format!("Aster's assessment: {reason}")
|
||||
});
|
||||
|
||||
// Feed Aster's direction back as a user message.
|
||||
// The next LLM round reads it as input.
|
||||
// Feed Aster's direction back as a system message —
|
||||
// her own channel, not impersonating the user.
|
||||
let msg = format!(
|
||||
"*[Aster's direction — {reason}]*\n{}",
|
||||
"[subconscious direction — {reason}]\n{}",
|
||||
correction,
|
||||
);
|
||||
messages.push(BifrostMessage::text("user", &msg));
|
||||
messages.push(BifrostMessage::text("system", &msg));
|
||||
let _ = tx.send(Ok(BackendEvent::Token(msg.clone()))).await;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -354,6 +354,10 @@ pub struct ChatState {
|
|||
/// Current itinerary route-line for the header strip.
|
||||
/// Empty string means no active itinerary.
|
||||
pub itinerary_line: String,
|
||||
|
||||
/// The human's preferred name, read from system/human.md frontmatter.
|
||||
/// Falls back to "you" if unset.
|
||||
pub human_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -441,11 +445,24 @@ impl ChatState {
|
|||
}
|
||||
}
|
||||
|
||||
// Try to load the human's nickname from the agent's memfs.
|
||||
let human_name = {
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
let p = std::path::PathBuf::from(home)
|
||||
.join(".souveraine")
|
||||
.join("agents")
|
||||
.join(&agent.id)
|
||||
.join("memory");
|
||||
crate::core::tools::nickname::read_human_name(&p)
|
||||
.unwrap_or_else(|| "you".to_string())
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
backend,
|
||||
mode: mode.to_string(),
|
||||
agent_name: agent.name,
|
||||
agent_id: agent.id,
|
||||
human_name,
|
||||
conversation_id,
|
||||
messages,
|
||||
input: String::new(),
|
||||
|
|
|
|||
|
|
@ -324,7 +324,7 @@ fn draw_messages(f: &mut Frame, state: &ChatState, area: Rect) {
|
|||
match msg {
|
||||
ChatMessage::User { text, .. } => {
|
||||
lines.extend(bubble(
|
||||
"⧉ you",
|
||||
&format!("⧉ {}", state.human_name),
|
||||
text,
|
||||
max_bubble,
|
||||
Style::default().fg(state.palette.user_accent),
|
||||
|
|
|
|||
Loading…
Reference in a new issue