hive-runtime, hive-agent: clear the ACP picker's pending model on a refusal
When an ACP agent answered session/set_config_option with an error, choose() only logged it, so the session's current value never moved and offered_pickers() kept treating the refused model as pending: the picker showed the refused model as current and hid the effort picker. hive-runtime now keeps the refused value per category on Choices, exposed as Choice::refused, set on the RPC error and cleared when the agent next accepts a value for that category. offered_pickers() treats a refused value as not settable, so the picker shows the session's actual model and its effort levels. The fake ACP agent gains REFUSE, which errors the first session/set_config_option to that value. Refs #4832 (ACP half only)
This commit is contained in:
parent
d74f567c59
commit
25a09dd3ec
2 changed files with 110 additions and 19 deletions
|
|
@ -538,9 +538,9 @@ pub(super) fn pickers(bus: &crate::events::Bus) -> Pickers {
|
||||||
|
|
||||||
/// Pickers for what an ACP session offers. The runtime sets a requested value
|
/// Pickers for what an ACP session offers. The runtime sets a requested value
|
||||||
/// on the next turn, and only if the session offers it, so each picker shows
|
/// on the next turn, and only if the session offers it, so each picker shows
|
||||||
/// the requested value when offered and the session's own otherwise. The
|
/// the requested value when offered and not refused by the agent, and the
|
||||||
/// effort levels on offer come with the model, so while a new model waits
|
/// session's own otherwise. The effort levels on offer come with the model,
|
||||||
/// for the next turn the effort picker is empty. `configured` narrows the
|
/// so while a new model waits for the next turn the effort picker is empty. `configured` narrows the
|
||||||
/// model list per [`filter_offered_models`]; it never narrows which value is
|
/// model list per [`filter_offered_models`]; it never narrows which value is
|
||||||
/// shown as current, only which values the picker offers alongside it.
|
/// shown as current, only which values the picker offers alongside it.
|
||||||
fn offered_pickers(
|
fn offered_pickers(
|
||||||
|
|
@ -549,14 +549,17 @@ fn offered_pickers(
|
||||||
offered: hive_runtime::SessionChoices,
|
offered: hive_runtime::SessionChoices,
|
||||||
configured: Option<&[String]>,
|
configured: Option<&[String]>,
|
||||||
) -> Pickers {
|
) -> Pickers {
|
||||||
|
let settable = |wanted: &String, c: &hive_runtime::Choice| {
|
||||||
|
c.values.contains(wanted) && c.refused.as_ref() != Some(wanted)
|
||||||
|
};
|
||||||
let shown = |wanted: String, choice: Option<&hive_runtime::Choice>| match choice {
|
let shown = |wanted: String, choice: Option<&hive_runtime::Choice>| match choice {
|
||||||
Some(c) if !c.values.contains(&wanted) => c.current.clone(),
|
Some(c) if !settable(&wanted, c) => c.current.clone(),
|
||||||
_ => wanted,
|
_ => wanted,
|
||||||
};
|
};
|
||||||
let model_pending = offered
|
let model_pending = offered
|
||||||
.model
|
.model
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|c| c.current != model && c.values.contains(&model));
|
.is_some_and(|c| c.current != model && settable(&model, c));
|
||||||
let effort_choice = offered.effort.filter(|_| !model_pending);
|
let effort_choice = offered.effort.filter(|_| !model_pending);
|
||||||
Pickers {
|
Pickers {
|
||||||
model: shown(model, offered.model.as_ref()),
|
model: shown(model, offered.model.as_ref()),
|
||||||
|
|
@ -581,6 +584,7 @@ mod tests {
|
||||||
Choice {
|
Choice {
|
||||||
current: current.to_owned(),
|
current: current.to_owned(),
|
||||||
values: values.iter().map(|v| (*v).to_owned()).collect(),
|
values: values.iter().map(|v| (*v).to_owned()).collect(),
|
||||||
|
refused: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -628,6 +632,26 @@ mod tests {
|
||||||
assert!(pending.available_efforts.is_empty());
|
assert!(pending.available_efforts.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acp_pickers_show_the_session_s_model_once_the_agent_refuses_the_wanted_one() {
|
||||||
|
let refused = SessionChoices {
|
||||||
|
model: Some(Choice {
|
||||||
|
refused: Some("m/plain".into()),
|
||||||
|
..choice("m/think", &["m/think", "m/plain"])
|
||||||
|
}),
|
||||||
|
effort: Some(choice("low", &["low", "high"])),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
offered_pickers("m/plain".into(), "high".into(), refused, None),
|
||||||
|
Pickers {
|
||||||
|
model: "m/think".into(),
|
||||||
|
available_models: strings(&["m/think", "m/plain"]),
|
||||||
|
effort: "high".into(),
|
||||||
|
available_efforts: strings(&["low", "high"]),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn acp_pickers_are_empty_before_a_session_offers_anything() {
|
fn acp_pickers_are_empty_before_a_session_offers_anything() {
|
||||||
let none = offered_pickers(
|
let none = offered_pickers(
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
mod rpc;
|
mod rpc;
|
||||||
mod stream;
|
mod stream;
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
@ -159,6 +159,8 @@ impl Canceller {
|
||||||
pub struct Choice {
|
pub struct Choice {
|
||||||
pub current: String,
|
pub current: String,
|
||||||
pub values: Vec<String>,
|
pub values: Vec<String>,
|
||||||
|
/// The value the agent last refused to set, until it accepts one.
|
||||||
|
pub refused: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The model and effort the loaded session lets the client pick, from its
|
/// The model and effort the loaded session lets the client pick, from its
|
||||||
|
|
@ -173,17 +175,26 @@ pub struct SessionChoices {
|
||||||
/// Reads the [`SessionChoices`] of an [`AcpRuntime`]'s session from outside
|
/// Reads the [`SessionChoices`] of an [`AcpRuntime`]'s session from outside
|
||||||
/// the call driving it. Cheap to clone.
|
/// the call driving it. Cheap to clone.
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
pub struct Choices(Arc<std::sync::Mutex<Vec<stream::ConfigOption>>>);
|
pub struct Choices(Arc<std::sync::Mutex<Offered>>);
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Offered {
|
||||||
|
options: Vec<stream::ConfigOption>,
|
||||||
|
/// Per category, the value the agent last refused to set.
|
||||||
|
refused: HashMap<&'static str, String>,
|
||||||
|
}
|
||||||
|
|
||||||
impl Choices {
|
impl Choices {
|
||||||
/// What the session offers now. Empty until a session is attached, and
|
/// What the session offers now. Empty until a session is attached, and
|
||||||
/// after the agent is respawned until one is again.
|
/// after the agent is respawned until one is again.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn get(&self) -> SessionChoices {
|
pub fn get(&self) -> SessionChoices {
|
||||||
|
let offered = self.lock();
|
||||||
let choice = |category| {
|
let choice = |category| {
|
||||||
self.find(category).map(|o| Choice {
|
find(&offered.options, category).map(|o| Choice {
|
||||||
current: o.current,
|
current: o.current.clone(),
|
||||||
values: o.values,
|
values: o.values.clone(),
|
||||||
|
refused: offered.refused.get(category).cloned(),
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
SessionChoices {
|
SessionChoices {
|
||||||
|
|
@ -193,16 +204,33 @@ impl Choices {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find(&self, category: &str) -> Option<stream::ConfigOption> {
|
fn find(&self, category: &str) -> Option<stream::ConfigOption> {
|
||||||
let options = self.0.lock().unwrap_or_else(PoisonError::into_inner);
|
find(&self.lock().options, category).cloned()
|
||||||
options
|
|
||||||
.iter()
|
|
||||||
.find(|o| o.category.as_deref() == Some(category))
|
|
||||||
.cloned()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn replace(&self, options: Vec<stream::ConfigOption>) {
|
fn replace(&self, options: Vec<stream::ConfigOption>) {
|
||||||
*self.0.lock().unwrap_or_else(PoisonError::into_inner) = options;
|
self.lock().options = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn refuse(&self, category: &'static str, value: &str) {
|
||||||
|
self.lock().refused.insert(category, value.to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accept(&self, category: &str) {
|
||||||
|
self.lock().refused.remove(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock(&self) -> std::sync::MutexGuard<'_, Offered> {
|
||||||
|
self.0.lock().unwrap_or_else(PoisonError::into_inner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find<'a>(
|
||||||
|
options: &'a [stream::ConfigOption],
|
||||||
|
category: &str,
|
||||||
|
) -> Option<&'a stream::ConfigOption> {
|
||||||
|
options
|
||||||
|
.iter()
|
||||||
|
.find(|o| o.category.as_deref() == Some(category))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marks a turn in flight for [`Canceller::cancel`] while it lives.
|
/// Marks a turn in flight for [`Canceller::cancel`] while it lives.
|
||||||
|
|
@ -803,12 +831,13 @@ async fn choose_model_and_effort(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set `session`'s config option of `category` to `wanted`, if the session
|
/// Set `session`'s config option of `category` to `wanted`, if the session
|
||||||
/// offers that value and holds another. A refusal is reported to `sink`, and
|
/// offers that value and holds another. A refusal is reported to `sink` and
|
||||||
|
/// kept as the choice's [`Choice::refused`] until the agent accepts a value;
|
||||||
/// the turn goes ahead on the value the session holds.
|
/// the turn goes ahead on the value the session holds.
|
||||||
async fn choose(
|
async fn choose(
|
||||||
live: &mut Live,
|
live: &mut Live,
|
||||||
session: &str,
|
session: &str,
|
||||||
category: &str,
|
category: &'static str,
|
||||||
wanted: Option<&str>,
|
wanted: Option<&str>,
|
||||||
sink: &impl Sink,
|
sink: &impl Sink,
|
||||||
) -> std::result::Result<(), AcpError> {
|
) -> std::result::Result<(), AcpError> {
|
||||||
|
|
@ -823,6 +852,7 @@ async fn choose(
|
||||||
let params = json!({ "sessionId": session, "configId": option.id, "value": wanted });
|
let params = json!({ "sessionId": session, "configId": option.id, "value": wanted });
|
||||||
match live.conn.request("session/set_config_option", params).await {
|
match live.conn.request("session/set_config_option", params).await {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
|
live.choices.accept(category);
|
||||||
if let Some(options) = stream::config_options(&response) {
|
if let Some(options) = stream::config_options(&response) {
|
||||||
live.offer(options);
|
live.offer(options);
|
||||||
}
|
}
|
||||||
|
|
@ -830,6 +860,7 @@ async fn choose(
|
||||||
Err(e @ AcpError::Rpc { .. }) => {
|
Err(e @ AcpError::Rpc { .. }) => {
|
||||||
tracing::warn!(error = %e, category, wanted, "ACP session/set_config_option failed");
|
tracing::warn!(error = %e, category, wanted, "ACP session/set_config_option failed");
|
||||||
sink.on_stderr_line(&format!("ACP agent refused {category} {wanted}: {e}"));
|
sink.on_stderr_line(&format!("ACP agent refused {category} {wanted}: {e}"));
|
||||||
|
live.choices.refuse(category, wanted);
|
||||||
}
|
}
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
}
|
}
|
||||||
|
|
@ -931,7 +962,8 @@ mod tests {
|
||||||
/// With `OPTIONS` set, its sessions offer models `m/think` (the default)
|
/// With `OPTIONS` set, its sessions offer models `m/think` (the default)
|
||||||
/// and `m/plain`, and effort levels `low` (the default) and `high` on
|
/// and `m/plain`, and effort levels `low` (the default) and `high` on
|
||||||
/// `m/think` only; it appends each `session/set_config_option` to
|
/// `m/think` only; it appends each `session/set_config_option` to
|
||||||
/// `<log>.sets` as `id=value`.
|
/// `<log>.sets` as `id=value`. With `REFUSE` set, it answers the first
|
||||||
|
/// `session/set_config_option` to that value with an error.
|
||||||
const AGENT: &str = r#"
|
const AGENT: &str = r#"
|
||||||
n=0 prompt= model=m/think effort=low
|
n=0 prompt= model=m/think effort=low
|
||||||
printf 'start\n' >> "$1.methods"
|
printf 'start\n' >> "$1.methods"
|
||||||
|
|
@ -976,6 +1008,11 @@ while IFS= read -r line; do
|
||||||
cid=${line#*\"configId\":\"}; cid=${cid%%\"*}
|
cid=${line#*\"configId\":\"}; cid=${cid%%\"*}
|
||||||
val=${line#*\"value\":\"}; val=${val%%\"*}
|
val=${line#*\"value\":\"}; val=${val%%\"*}
|
||||||
printf '%s=%s\n' "$cid" "$val" >> "$1.sets"
|
printf '%s=%s\n' "$cid" "$val" >> "$1.sets"
|
||||||
|
if [ -n "$REFUSE" ] && [ "$val" = "$REFUSE" ] && [ ! -e "$1.refused" ]; then
|
||||||
|
: > "$1.refused"
|
||||||
|
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"unknown value"}}\n' "$id"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
case $cid in model) model=$val ;; effort) effort=$val ;; esac
|
case $cid in model) model=$val ;; effort) effort=$val ;; esac
|
||||||
result "$id" ;;
|
result "$id" ;;
|
||||||
session/prompt)
|
session/prompt)
|
||||||
|
|
@ -1460,6 +1497,7 @@ done
|
||||||
Choice {
|
Choice {
|
||||||
current: current.to_owned(),
|
current: current.to_owned(),
|
||||||
values: values.iter().map(|v| (*v).to_owned()).collect(),
|
values: values.iter().map(|v| (*v).to_owned()).collect(),
|
||||||
|
refused: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1511,4 +1549,33 @@ done
|
||||||
runtime.run(&config, "three", &NoopSink).await.unwrap();
|
runtime.run(&config, "three", &NoopSink).await.unwrap();
|
||||||
assert_eq!(sets(dir.path()).len(), 2);
|
assert_eq!(sets(dir.path()).len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_refused_value_is_kept_until_the_agent_accepts_one() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let env = [("OPTIONS", "1"), ("REFUSE", "m/plain")];
|
||||||
|
let runtime = agent(dir.path(), "ok", &env, policy(0));
|
||||||
|
let choices = runtime.choices().unwrap();
|
||||||
|
let config = Config {
|
||||||
|
model: Some("m/plain".into()),
|
||||||
|
..config(dir.path())
|
||||||
|
};
|
||||||
|
|
||||||
|
runtime.run(&config, "one", &NoopSink).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
choices.get().model,
|
||||||
|
Some(Choice {
|
||||||
|
refused: Some("m/plain".into()),
|
||||||
|
..choice("m/think", &["m/think", "m/plain"])
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// The next turn asks again, and the agent accepts.
|
||||||
|
runtime.run(&config, "two", &NoopSink).await.unwrap();
|
||||||
|
assert_eq!(sets(dir.path()), ["model=m/plain", "model=m/plain"]);
|
||||||
|
assert_eq!(
|
||||||
|
choices.get().model,
|
||||||
|
Some(choice("m/plain", &["m/think", "m/plain"]))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue