use super::question::{Question, QuestionKind};
use super::{DecisionError, DecisionModel};
use crate::types::{TurnContext, TurnHook};
use serde_json::json;
use std::hash::{Hash, Hasher};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::Duration;
const MAX_REQUEST_CHARS: usize = 8_000;
const MAX_DESCRIPTION_CHARS: usize = 300;
pub(crate) fn assert_threshold(name: &str, p: f64) {
assert!(
(0.0..=1.0).contains(&p),
"{name} must be a probability in [0, 1], got {p}"
);
}
#[derive(Debug, Clone)]
pub struct Advisory {
pub(crate) model: DecisionModel,
skill_hint: bool,
skill_need_threshold: f64,
skill_confidence_threshold: f64,
tool_hint: bool,
tool_hint_min_tools: usize,
max_tool_hints: usize,
tool_hint_min_probability: f64,
timeout: Duration,
}
impl Advisory {
pub fn new(model: DecisionModel) -> Self {
Self {
model,
skill_hint: true,
skill_need_threshold: 0.3,
skill_confidence_threshold: 0.5,
tool_hint: true,
tool_hint_min_tools: 40,
max_tool_hints: 3,
tool_hint_min_probability: 0.1,
timeout: Duration::from_secs(2),
}
}
pub fn with_skill_hint(mut self, on: bool) -> Self {
self.skill_hint = on;
self
}
pub fn with_skill_need_threshold(mut self, p: f64) -> Self {
assert_threshold("skill need threshold", p);
self.skill_need_threshold = p;
self
}
pub fn with_skill_confidence_threshold(mut self, p: f64) -> Self {
assert_threshold("skill confidence threshold", p);
self.skill_confidence_threshold = p;
self
}
pub fn with_tool_hint(mut self, on: bool) -> Self {
self.tool_hint = on;
self
}
pub fn with_tool_hint_min_tools(mut self, n: usize) -> Self {
self.tool_hint_min_tools = n;
self
}
pub fn with_max_tool_hints(mut self, n: usize) -> Self {
self.max_tool_hints = n;
self
}
pub fn with_tool_hint_min_probability(mut self, p: f64) -> Self {
assert_threshold("tool hint minimum probability", p);
self.tool_hint_min_probability = p;
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
}
pub(crate) struct Advisor {
advisory: Advisory,
model: DecisionModel,
skills: Vec<(String, String)>,
memo: Mutex<Option<(u64, Option<String>)>>,
said_idle: AtomicBool,
said_missing_key: AtomicBool,
said_too_many_skills: AtomicBool,
}
impl Advisor {
pub(crate) fn new(advisory: Advisory, skills: &crate::skills::SkillSet) -> Self {
let mut seen = std::collections::HashSet::new();
let skills = skills
.skills()
.iter()
.filter(|s| seen.insert(s.name.clone()))
.map(|s| {
(
s.name.clone(),
truncate_head(&s.description, MAX_DESCRIPTION_CHARS),
)
})
.collect();
let model = advisory.model.clone().with_timeout(advisory.timeout);
Self {
advisory,
model,
skills,
memo: Mutex::new(None),
said_idle: AtomicBool::new(false),
said_missing_key: AtomicBool::new(false),
said_too_many_skills: AtomicBool::new(false),
}
}
fn none_option(&self) -> &'static str {
if self.skills.iter().any(|(n, _)| n == "none") {
"no_skill"
} else {
"none"
}
}
async fn advise(&self, turn: &TurnContext<'_>) -> Option<String> {
let caps = self.model.capabilities();
let choice_ok = caps.supports(QuestionKind::Choice);
let too_many_skills = self.skills.len() >= caps.max_choice_options;
if self.advisory.skill_hint
&& too_many_skills
&& !self.said_too_many_skills.swap(true, Ordering::Relaxed)
{
tracing::warn!(
skills = self.skills.len(),
limit = caps.max_choice_options,
"decision advisory skill hint off: more skills than the backend's Choice \
option limit (one option is reserved for \"none\")"
);
}
let want_skill = self.advisory.skill_hint
&& !self.skills.is_empty()
&& choice_ok
&& caps.supports(QuestionKind::Noul)
&& !too_many_skills;
let want_tools = self.advisory.tool_hint
&& choice_ok
&& turn.tools.len() >= self.advisory.tool_hint_min_tools.max(2)
&& turn.tools.len() <= caps.max_choice_options;
if !want_skill && !want_tools {
if !self.said_idle.swap(true, Ordering::Relaxed) {
tracing::debug!(
skills = self.skills.len(),
tools = turn.tools.len(),
"decision advisory has nothing to ask: no skills to suggest and fewer \
than {} tools, so no decision request is sent",
self.advisory.tool_hint_min_tools
);
}
return None;
}
let request = turn.user_request()?;
let key = {
let mut h = std::collections::hash_map::DefaultHasher::new();
request.hash(&mut h);
want_skill.hash(&mut h);
if want_tools {
for t in turn.tools {
t.name.hash(&mut h);
}
}
h.finish()
};
if let Some((k, line)) = self.memo.lock().unwrap_or_else(|e| e.into_inner()).as_ref() {
if *k == key {
return line.clone();
}
}
let mut questions = Vec::new();
let none = self.none_option();
if want_skill {
let mut options: Vec<(String, String)> = self.skills.clone();
options.push((none.into(), "No listed skill fits the request.".into()));
questions.push((
"skill".to_string(),
Question::choice_with_criteria(
"Which of these skills, if any, is the right one to load to help with \
the user's latest request in `request`?",
options,
),
));
questions.push((
"skill_needed".to_string(),
Question::noul(
"Would a careful expert handling `request` consult a specific documented \
procedure or set of commands, rather than answering from general \
understanding?",
),
));
}
if want_tools {
questions.push((
"tools".to_string(),
Question::choice_with_criteria(
"Which of these tools will the assistant most likely need next to carry \
out `request`?",
turn.tools.iter().map(|t| {
let desc = truncate_head(&t.description, MAX_DESCRIPTION_CHARS);
let desc = if desc.trim().is_empty() {
t.name.clone()
} else {
desc
};
(t.name.clone(), desc)
}),
),
));
}
let state = json!({ "request": truncate_middle(&request, MAX_REQUEST_CHARS) });
let eval = match self.model.evaluate(state, questions).await {
Ok(eval) => eval,
Err(DecisionError::MissingApiKey(var)) => {
if !self.said_missing_key.swap(true, Ordering::Relaxed) {
tracing::warn!(
"decision advisory disabled: the decision model's API key is missing \
(set {var}); the agent continues without hints"
);
}
self.remember(key, None);
return None;
}
Err(e) => {
tracing::warn!("decision advisory skipped this request: {e}");
self.remember(key, None);
return None;
}
};
let mut lines = Vec::new();
if want_skill {
let needed = eval.p_true("skill_needed").unwrap_or(0.0);
if let Some(choice) = eval.choice("skill") {
if needed >= self.advisory.skill_need_threshold
&& choice.choice() != none
&& choice.confidence() >= self.advisory.skill_confidence_threshold
{
lines.push(format!(
"Relevant to the current request: {}. Ignore this if it does not fit \
what the user actually asked for.",
choice.choice()
));
}
}
}
if want_tools {
if let Some(choice) = eval.choice("tools") {
let named: Vec<&str> = choice
.ranked()
.into_iter()
.filter(|(_, p)| *p >= self.advisory.tool_hint_min_probability)
.take(self.advisory.max_tool_hints)
.map(|(name, _)| name)
.collect();
if !named.is_empty() {
lines.push(format!(
"Tools likely relevant to the current request: {}. This is a hint \
only; every tool remains available.",
named.join(", ")
));
}
}
}
let line = (!lines.is_empty()).then(|| lines.join("\n"));
self.remember(key, line.clone());
line
}
fn remember(&self, key: u64, line: Option<String>) {
*self.memo.lock().unwrap_or_else(|e| e.into_inner()) = Some((key, line));
}
}
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
impl TurnHook for Advisor {
async fn before_turn(&self, turn: &TurnContext<'_>) -> Option<String> {
self.advise(turn).await
}
}
pub(crate) fn truncate_head(text: &str, max: usize) -> String {
match text.char_indices().nth(max) {
Some((end, _)) => format!("{}…", &text[..end]),
None => text.to_string(),
}
}
pub(crate) fn truncate_middle(text: &str, max: usize) -> String {
let len = text.chars().count();
if len <= max {
return text.to_string();
}
let keep = max / 2;
let head: String = text.chars().take(keep).collect();
let tail: String = text.chars().skip(len - keep).collect();
format!("{head}\n[truncated {} chars]\n{tail}", len - 2 * keep)
}