memoryd: ask_memory, each workspace's chat at once with its own limit
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
//! `ask_memory` (M5c spec, section 9): the one or two workspaces named, each asked by Honcho's
|
||||
//! workspace chat at `minimal`, both at once, each on its own thread with its own limit; the
|
||||
//! answers labelled as `recall` hits are. A name no workspace has, or a question that holds a
|
||||
//! secret, asks nothing. Runs outside `memoryd`'s state lock; a chat still running at its limit is
|
||||
//! left to finish, and its answer is dropped.
|
||||
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bxcore::AskMemoryArgs;
|
||||
use roncho::{DialecticOptions, ReasoningLevel};
|
||||
|
||||
use crate::honcho::client::{CHAT_READ, HonchoError, classify};
|
||||
use crate::honcho::decide::has_secret;
|
||||
use crate::honcho::read::{Label, Reader, Readers};
|
||||
use crate::honcho::recalled::{Item, label, render, unavailable};
|
||||
use crate::recall::{Recalled, one_line};
|
||||
use crate::serve::Log;
|
||||
|
||||
/// How long each chat has, counted from when the chats start (M5c spec, section 9).
|
||||
pub const CHAT_WAIT: Duration = CHAT_READ;
|
||||
|
||||
/// The most characters of one answer: a chat answers with a paragraph or two of reasoning, not a
|
||||
/// snippet, so its cap is larger than `recall`'s 600.
|
||||
pub const ANSWER_CAP: usize = 2_000;
|
||||
|
||||
/// The answer when the question holds a known-prefix secret.
|
||||
pub const SECRET: &str = "ask_memory: not asked; the question looks like it holds a secret";
|
||||
|
||||
/// The text of an answer with no content.
|
||||
pub const NO_ANSWER: &str = "(no answer)";
|
||||
|
||||
/// One workspace asked: its label, and its answer's content, `None` when it did not answer.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Chat {
|
||||
pub label: Label,
|
||||
pub answer: Option<String>,
|
||||
}
|
||||
|
||||
/// What `ask` did.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Asked {
|
||||
/// Nothing was sent: the sentence the model is shown.
|
||||
NotSent(String),
|
||||
/// Each workspace named, in the order named.
|
||||
Sent(Vec<Chat>),
|
||||
}
|
||||
|
||||
/// One workspace's chat at `minimal`: its answer's content, or why it failed. The blocking
|
||||
/// client refuses a workspace chat with a `target` or `filters`; neither is set.
|
||||
pub fn chat(reader: &Reader, question: &str) -> Result<String, HonchoError> {
|
||||
let options = DialecticOptions {
|
||||
query: question.to_string(),
|
||||
reasoning_level: Some(ReasoningLevel::Minimal),
|
||||
..Default::default()
|
||||
};
|
||||
let response = reader
|
||||
.chat()
|
||||
.workspace_chat(&options)
|
||||
.map_err(|e| classify(&e))?;
|
||||
Ok(response.content().to_string())
|
||||
}
|
||||
|
||||
/// Ask each workspace `args` names (`own` when it names none) at once, each on a thread of its
|
||||
/// own, and wait for each at most `wait` from now. A chat that failed, or that has not answered
|
||||
/// by then, has no answer; each is logged by its name and the error's kind and status, never the
|
||||
/// question or an answer. `NotSent` with nothing sent when a name is none of `readers`'
|
||||
/// (`resolve`'s sentence) or the question holds a known-prefix secret.
|
||||
pub fn ask(readers: &Readers, args: &AskMemoryArgs, wait: Duration, log: &Log) -> Asked {
|
||||
let mut asked: Vec<Reader> = Vec::new();
|
||||
for name in args.asked() {
|
||||
match readers.resolve(name) {
|
||||
Ok(reader) => asked.push(reader.clone()),
|
||||
Err(sentence) => return Asked::NotSent(sentence),
|
||||
}
|
||||
}
|
||||
if has_secret(&args.question) {
|
||||
return Asked::NotSent(SECRET.to_string());
|
||||
}
|
||||
let deadline = Instant::now() + wait;
|
||||
let (tx, rx) = mpsc::channel::<(usize, Result<String, HonchoError>)>();
|
||||
let mut answers: Vec<Option<Result<String, HonchoError>>> = vec![None; asked.len()];
|
||||
for (i, slot) in answers.iter_mut().enumerate() {
|
||||
let sender = tx.clone();
|
||||
let reader = asked.get(i).cloned();
|
||||
let question = args.question.clone();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("honcho-ask".to_string())
|
||||
.spawn(move || {
|
||||
if let Some(reader) = reader {
|
||||
let _ = sender.send((i, chat(&reader, &question)));
|
||||
}
|
||||
});
|
||||
if spawned.is_err() {
|
||||
*slot = Some(Err(HonchoError::Unreachable {
|
||||
kind: "thread",
|
||||
status: None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
drop(tx);
|
||||
while answers.iter().any(Option::is_none) {
|
||||
let left = deadline.saturating_duration_since(Instant::now());
|
||||
match rx.recv_timeout(left) {
|
||||
Ok((i, answer)) => {
|
||||
if let Some(slot) = answers.get_mut(i) {
|
||||
*slot = Some(answer);
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
let mut chats = Vec::with_capacity(asked.len());
|
||||
for (reader, answer) in asked.into_iter().zip(answers) {
|
||||
let name = &reader.label.name;
|
||||
let answer = match answer {
|
||||
Some(Ok(content)) => Some(content),
|
||||
Some(Err(e)) => {
|
||||
(log)(&format!("memoryd: ask_memory: {name} unavailable: {e}"));
|
||||
None
|
||||
}
|
||||
None => {
|
||||
(log)(&format!(
|
||||
"memoryd: ask_memory: {name} unavailable: no answer in {} ms",
|
||||
wait.as_millis()
|
||||
));
|
||||
None
|
||||
}
|
||||
};
|
||||
chats.push(Chat {
|
||||
label: reader.label,
|
||||
answer,
|
||||
});
|
||||
}
|
||||
Asked::Sent(chats)
|
||||
}
|
||||
|
||||
/// `ask_memory`'s answer: each workspace that answered, in the order named, as an item whose
|
||||
/// header names the workspace (`[1] memory · own · private`) and whose text is its answer on one
|
||||
/// indented line, cut to `ANSWER_CAP` characters (`(no answer)` when it is empty); then one
|
||||
/// `{name}: unavailable` line for each that did not. `NotSent`'s sentence alone, labelled
|
||||
/// `private` and trusted. The label is the highest class of the answers shown, `private` at the
|
||||
/// least, and untrusted when any is.
|
||||
pub fn answer(asked: Asked) -> Recalled {
|
||||
match asked {
|
||||
Asked::NotSent(sentence) => Recalled {
|
||||
text: sentence,
|
||||
class: bxcore::DataClass::Private,
|
||||
untrusted: false,
|
||||
},
|
||||
Asked::Sent(chats) => {
|
||||
let mut items: Vec<Item> = Vec::new();
|
||||
let mut names: Vec<&str> = Vec::new();
|
||||
for chat in &chats {
|
||||
match &chat.answer {
|
||||
Some(content) => {
|
||||
let text = one_line(content, ANSWER_CAP);
|
||||
let text = if text.trim().is_empty() {
|
||||
NO_ANSWER.to_string()
|
||||
} else {
|
||||
text
|
||||
};
|
||||
items.push(Item {
|
||||
tag: chat.label.tag(),
|
||||
from: chat.label.name.clone(),
|
||||
class: chat.label.class,
|
||||
untrusted: chat.label.untrusted(),
|
||||
text,
|
||||
});
|
||||
}
|
||||
None => names.push(chat.label.name.as_str()),
|
||||
}
|
||||
}
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if !items.is_empty() {
|
||||
parts.push(render(1, &items));
|
||||
}
|
||||
if !names.is_empty() {
|
||||
parts.push(unavailable(&names));
|
||||
}
|
||||
let (class, untrusted) = label(&items);
|
||||
Recalled {
|
||||
text: parts.join("\n"),
|
||||
class,
|
||||
untrusted,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Honcho, the memory this agent shares with the fleet, reached through `roncho` (M5c spec).
|
||||
|
||||
pub mod ask;
|
||||
pub mod client;
|
||||
pub mod concluded;
|
||||
pub mod config;
|
||||
|
||||
@@ -30,10 +30,16 @@ pub struct Local {
|
||||
pub untrusted: bool,
|
||||
}
|
||||
|
||||
/// A hit's text as an item holds it, from the local index or from Honcho: neutralised, every
|
||||
/// control character (a line break among them) and every Unicode line or paragraph separator a
|
||||
/// space, then cut to its first `SNIPPET` characters, with no mark.
|
||||
/// A hit's text as an item holds it, from the local index or from Honcho: `one_line`, cut to
|
||||
/// `SNIPPET` characters.
|
||||
pub fn snippet(text: &str) -> String {
|
||||
one_line(text, SNIPPET)
|
||||
}
|
||||
|
||||
/// Text from memory as an item holds it: neutralised, every control character (a line break
|
||||
/// among them) and every Unicode line or paragraph separator a space, then cut to its first
|
||||
/// `cap` characters, with no mark.
|
||||
pub fn one_line(text: &str, cap: usize) -> String {
|
||||
neutralise(text)
|
||||
.chars()
|
||||
.map(|c| {
|
||||
@@ -43,7 +49,7 @@ pub fn snippet(text: &str) -> String {
|
||||
c
|
||||
}
|
||||
})
|
||||
.take(SNIPPET)
|
||||
.take(cap)
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user