Watch
0
0
Fork
You've already forked hyperhive
0

hive-runtime: drop an ACP refusal on respawn and when the held value is wanted

Choice::refused was cleared only when the agent accepted a
session/set_config_option, so it outlived what it described in two cases:
a respawned agent process kept the dead one's refusal masking the wanted
value in the picker, and a refusal survived a turn whose wanted value was
the one the session already held (or none), so re-picking the refused
value snapped the picker back to the session's value.

start() now resets the refusals along with the offered options, and
choose() clears the category's refusal when the wanted value is none or
the one the session holds.

Refs #4832
This commit is contained in:
atlas 2026-09-30 13:03:48 +02:00 • committed by mara
commit 7ccbeaa161

View file

@ -159,7 +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. /// The value the agent last refused to set, until it accepts one, the
/// value wanted is the one the session holds, or the agent is respawned.
pub refused: Option<String>, pub refused: Option<String>,
} }
@ -211,6 +212,10 @@ impl Choices {
self.lock().options = options; self.lock().options = options;
} }
fn reset(&self) {
*self.lock() = Offered::default();
}
fn refuse(&self, category: &'static str, value: &str) { fn refuse(&self, category: &'static str, value: &str) {
self.lock().refused.insert(category, value.to_owned()); self.lock().refused.insert(category, value.to_owned());
} }
@ -368,7 +373,7 @@ impl<P: CompactionPolicy> AcpRuntime<P> {
return Err(AcpError::NoHttpMcp.into()); return Err(AcpError::NoHttpMcp.into());
} }
tracing::info!(agent = %init["agentInfo"], "ACP agent initialized"); tracing::info!(agent = %init["agentInfo"], "ACP agent initialized");
self.choices.replace(Vec::new()); self.choices.reset();
Ok(Live { Ok(Live {
conn, conn,
load_session: caps["loadSession"] == Value::Bool(true), load_session: caps["loadSession"] == Value::Bool(true),
@ -832,8 +837,9 @@ 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; /// kept as the choice's [`Choice::refused`] until the agent accepts a value
/// the turn goes ahead on the value the session holds. /// or `wanted` is none or 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,
@ -844,9 +850,11 @@ async fn choose(
let Some(option) = live.choices.find(category) else { let Some(option) = live.choices.find(category) else {
return Ok(()); return Ok(());
}; };
let Some(wanted) = if wanted.is_none_or(|w| w == option.current) {
wanted.filter(|w| *w != option.current && option.values.iter().any(|v| v == w)) live.choices.accept(category);
else { return Ok(());
}
let Some(wanted) = wanted.filter(|w| option.values.iter().any(|v| v == w)) else {
return Ok(()); return Ok(());
}; };
let params = json!({ "sessionId": session, "configId": option.id, "value": wanted }); let params = json!({ "sessionId": session, "configId": option.id, "value": wanted });
@ -1578,4 +1586,57 @@ done
Some(choice("m/plain", &["m/think", "m/plain"])) Some(choice("m/plain", &["m/think", "m/plain"]))
); );
} }
#[tokio::test]
async fn a_refusal_is_dropped_when_the_agent_is_respawned() {
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 mut config = Config {
model: Some("m/plain".into()),
..config(dir.path())
};
runtime.run(&config, "one", &NoopSink).await.unwrap();
assert_eq!(
choices.get().model.and_then(|c| c.refused).as_deref(),
Some("m/plain")
);
// `haiku` is not on offer, so the new process is asked to set nothing.
*runtime.live.lock().await = None;
config.model = Some("haiku".into());
runtime.run(&config, "two", &NoopSink).await.unwrap();
assert_eq!(count(dir.path(), "start"), 2);
assert_eq!(sets(dir.path()), ["model=m/plain"]);
assert_eq!(
choices.get().model,
Some(choice("m/think", &["m/think", "m/plain"]))
);
}
#[tokio::test]
async fn a_refusal_is_dropped_when_the_session_s_own_value_is_wanted() {
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 mut config = Config {
model: Some("m/plain".into()),
..config(dir.path())
};
runtime.run(&config, "one", &NoopSink).await.unwrap();
assert_eq!(
choices.get().model.and_then(|c| c.refused).as_deref(),
Some("m/plain")
);
config.model = Some("m/think".into());
runtime.run(&config, "two", &NoopSink).await.unwrap();
assert_eq!(sets(dir.path()), ["model=m/plain"]);
assert_eq!(
choices.get().model,
Some(choice("m/think", &["m/think", "m/plain"]))
);
}
} }