Watch
0
0
Fork
You've already forked hyperhive
0

hive-runtime, hive-agent: model/effort picker for ACP agents from configOptions

An ACP agent's model and effort pickers now list what its session offers
(its `model` and `thought_level` config options) instead of the claude
model list and EFFORT_LEVELS. A pick goes through the same Bus::set_model /
Bus::set_effort -> Config.model / Config.effort path as on claude; before
each prompt the ACP runtime sets it with `session/set_config_option`, model
first, and only when the session offers that value. Options are re-read
from the set response and from `config_option_update`, so the effort picker
disappears when the chosen model offers no effort levels, and is hidden
while a newly picked model waits for the next turn.

The session's options reach the web UI through a `Choices` handle from a
new `Runtime::choices`, registered on the bus the way `canceller` is.
/api/model and /api/effort accept only offered values on ACP. The claude
path is unchanged.

Refs #4391
This commit is contained in:
atlas 2026-09-30 07:59:27 +02:00
commit f189724a4c
13 changed files with 559 additions and 44 deletions

View file

@ -11,8 +11,8 @@ mod stream;
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, PoisonError};
use std::time::Duration;
use hive_claude::{CompactionPolicy, Config, Progress, Sink};
@ -54,6 +54,10 @@ const COMPACT_COMMAND: &str = "compact";
/// session is attached, or never.
const COMMANDS_WAIT: Duration = SETTLE_MAX;
/// The config option categories [`Config::model`] and [`Config::effort`] set.
const MODEL_CATEGORY: &str = "model";
const EFFORT_CATEGORY: &str = "thought_level";
/// One `session/request_permission` request, as a [`PermissionPolicy`] sees it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PermissionAsk<'a> {
@ -149,6 +153,58 @@ impl Canceller {
}
}
/// A value the session lets the client pick: the one it has, and those it
/// accepts.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Choice {
pub current: String,
pub values: Vec<String>,
}
/// The model and effort the loaded session lets the client pick, from its
/// first `model` and `thought_level` config options. `None` where it offers
/// none; which effort levels a session offers can change with its model.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SessionChoices {
pub model: Option<Choice>,
pub effort: Option<Choice>,
}
/// Reads the [`SessionChoices`] of an [`AcpRuntime`]'s session from outside
/// the call driving it. Cheap to clone.
#[derive(Clone, Default)]
pub struct Choices(Arc<std::sync::Mutex<Vec<stream::ConfigOption>>>);
impl Choices {
/// What the session offers now. Empty until a session is attached, and
/// after the agent is respawned until one is again.
#[must_use]
pub fn get(&self) -> SessionChoices {
let choice = |category| {
self.find(category).map(|o| Choice {
current: o.current,
values: o.values,
})
};
SessionChoices {
model: choice(MODEL_CATEGORY),
effort: choice(EFFORT_CATEGORY),
}
}
fn find(&self, category: &str) -> Option<stream::ConfigOption> {
let options = self.0.lock().unwrap_or_else(PoisonError::into_inner);
options
.iter()
.find(|o| o.category.as_deref() == Some(category))
.cloned()
}
fn replace(&self, options: Vec<stream::ConfigOption>) {
*self.0.lock().unwrap_or_else(PoisonError::into_inner) = options;
}
}
/// Marks a turn in flight for [`Canceller::cancel`] while it lives.
struct InTurn<'a>(&'a AtomicBool);
@ -183,7 +239,9 @@ enum TurnKind {
/// since ACP has no system prompt) and `idle_timeout` (with no
/// `session/update` for that long the turn is cancelled, and fails with
/// [`AcpError::IdleTimeout`]). Everything else in it is claude's and is
/// ignored.
/// ignored, except `model` and `effort`: before each prompt they are set as
/// the session's `model` and then `thought_level` config option, when the
/// session offers that value. [`Runtime::choices`] reads what it offers.
///
/// Compaction, proactive once `policy` says so after a turn or on
/// [`Runtime::compact`], runs the agent's advertised `compact` command as a
@ -199,6 +257,7 @@ pub struct AcpRuntime<P: CompactionPolicy> {
cancel: Arc<CancelState>,
cancel_grace: Duration,
compact_idle: Duration,
choices: Choices,
}
/// The running agent process and the session loaded into it.
@ -211,6 +270,20 @@ struct Live {
/// The commands the agent last advertised for `loaded`; `None` until it
/// has.
commands: Option<Vec<String>>,
/// The config options `loaded` last reported.
choices: Choices,
}
impl Live {
fn offer(&mut self, options: Vec<stream::ConfigOption>) {
if let Some(model) = options
.iter()
.find(|o| o.category.as_deref() == Some(MODEL_CATEGORY))
{
self.model = Some(model.current.clone());
}
self.choices.replace(options);
}
}
impl<P: CompactionPolicy> AcpRuntime<P> {
@ -233,6 +306,7 @@ impl<P: CompactionPolicy> AcpRuntime<P> {
cancel: Arc::default(),
cancel_grace: CANCEL_GRACE,
compact_idle: COMPACT_IDLE,
choices: Choices::default(),
}
}
@ -266,12 +340,14 @@ impl<P: CompactionPolicy> AcpRuntime<P> {
return Err(AcpError::NoHttpMcp.into());
}
tracing::info!(agent = %init["agentInfo"], "ACP agent initialized");
self.choices.replace(Vec::new());
Ok(Live {
conn,
load_session: caps["loadSession"] == Value::Bool(true),
loaded: None,
model: None,
commands: None,
choices: self.choices.clone(),
})
}
@ -303,6 +379,7 @@ impl<P: CompactionPolicy> AcpRuntime<P> {
match live.conn.request("session/load", params).await {
Ok(response) => {
live.model = stream::session_model(&response);
live.offer(stream::config_options(&response).unwrap_or_default());
live.loaded = Some(id.clone());
live.commands = None;
discard_stale(live)?;
@ -324,6 +401,7 @@ impl<P: CompactionPolicy> AcpRuntime<P> {
})?
.to_owned();
live.model = stream::session_model(&response);
live.offer(stream::config_options(&response).unwrap_or_default());
live.loaded = Some(id.clone());
live.commands = None;
write_id(&self.pending_file(), &id)?;
@ -347,6 +425,7 @@ impl<P: CompactionPolicy> AcpRuntime<P> {
let cwd = session_cwd(config);
let (servers, names) = mcp_servers(config)?;
let (session, created) = self.attach(live, &cwd, &servers).await?;
choose_model_and_effort(live, &session, config, sink).await?;
let text = prompt_text(config, prompt, created);
let params = json!({ "sessionId": session, "prompt": [{ "type": "text", "text": text }] });
discard_stale(live)?;
@ -593,6 +672,10 @@ impl<P: CompactionPolicy> Runtime for AcpRuntime<P> {
Some(Canceller(self.cancel.clone()))
}
fn choices(&self) -> Option<Choices> {
Some(self.choices.clone())
}
/// Moves the session file aside; the agent keeps its own copy of the
/// session, so nothing is lost. A new session whose first prompt was never
/// answered is dropped too.
@ -625,9 +708,7 @@ fn deliver(
match incoming {
Incoming::Update(params) => {
if params["sessionId"].as_str() == agent.loaded.as_deref() {
if let Some(advertised) = stream::advertised_commands(&params["update"]) {
agent.commands = Some(advertised);
}
keep_advertised(agent, &params["update"]);
for event in mapper.push(&params["update"]) {
sink.on_event(&event);
}
@ -646,10 +727,22 @@ fn deliver(
true
}
/// Keep the commands and config options an `update` for the loaded session
/// advertises.
fn keep_advertised(agent: &mut Live, update: &Value) {
if let Some(advertised) = stream::advertised_commands(update) {
agent.commands = Some(advertised);
}
if let Some(options) = stream::updated_config_options(update) {
agent.offer(options);
}
}
/// Drop what the agent sent outside a turn: the history a `session/load`
/// replays (the caller already has it), and anything that arrived after the
/// previous turn settled, which must not be shown as part of the next one.
/// The commands the agent advertises for the loaded session are kept.
/// The commands and config options the agent advertises for the loaded
/// session are kept.
fn discard_stale(live: &mut Live) -> std::result::Result<(), AcpError> {
while let Ok(incoming) = live.conn.incoming.try_recv() {
between_turns(live, incoming)?;
@ -660,10 +753,8 @@ fn discard_stale(live: &mut Live) -> std::result::Result<(), AcpError> {
fn between_turns(agent: &mut Live, incoming: Incoming) -> std::result::Result<(), AcpError> {
match incoming {
Incoming::Update(params) => {
if params["sessionId"].as_str() == agent.loaded.as_deref()
&& let Some(advertised) = stream::advertised_commands(&params["update"])
{
agent.commands = Some(advertised);
if params["sessionId"].as_str() == agent.loaded.as_deref() {
keep_advertised(agent, &params["update"]);
}
}
Incoming::Stdout(line) | Incoming::Stderr(line) => {
@ -692,6 +783,59 @@ async fn advertises_compact(live: &mut Live) -> std::result::Result<bool, AcpErr
.any(|name| name == COMPACT_COMMAND))
}
/// Set `config`'s model, then its effort, on `session`: the effort levels
/// on offer depend on the model.
async fn choose_model_and_effort(
live: &mut Live,
session: &str,
config: &Config,
sink: &impl Sink,
) -> std::result::Result<(), AcpError> {
choose(live, session, MODEL_CATEGORY, config.model.as_deref(), sink).await?;
choose(
live,
session,
EFFORT_CATEGORY,
config.effort.as_deref(),
sink,
)
.await
}
/// 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
/// the turn goes ahead on the value the session holds.
async fn choose(
live: &mut Live,
session: &str,
category: &str,
wanted: Option<&str>,
sink: &impl Sink,
) -> std::result::Result<(), AcpError> {
let Some(option) = live.choices.find(category) else {
return Ok(());
};
let Some(wanted) =
wanted.filter(|w| *w != option.current && option.values.iter().any(|v| v == w))
else {
return Ok(());
};
let params = json!({ "sessionId": session, "configId": option.id, "value": wanted });
match live.conn.request("session/set_config_option", params).await {
Ok(response) => {
if let Some(options) = stream::config_options(&response) {
live.offer(options);
}
}
Err(e @ AcpError::Rpc { .. }) => {
tracing::warn!(error = %e, category, wanted, "ACP session/set_config_option failed");
sink.on_stderr_line(&format!("ACP agent refused {category} {wanted}: {e}"));
}
Err(e) => return Err(e),
}
Ok(())
}
/// The prompt as sent: the first one of a session carries the system prompt.
fn prompt_text(config: &Config, prompt: &str, first: bool) -> String {
match (first, &config.system_prompt_file) {
@ -758,7 +902,7 @@ mod tests {
use hive_claude::{Config, NoopSink, PercentPolicy, Sink};
use super::{AcpError, AcpRuntime, TurnKind};
use super::{AcpError, AcpRuntime, Choice, SessionChoices, TurnKind};
use crate::{AcpCommand, Error, Runtime};
/// An ACP agent in plain `sh`. It answers `initialize`, numbers its
@ -784,8 +928,12 @@ mod tests {
/// tokens used on each prompt to session `s1`. With `STALL_COMPACT` set, it
/// answers `/compact` only when cancelled, as `silent` does. With `BLANK`
/// set, it answers a prompt whose text is exactly that as `blank` does.
/// With `OPTIONS` set, its sessions offer models `m/think` (the default)
/// and `m/plain`, and effort levels `low` (the default) and `high` on
/// `m/think` only; it appends each `session/set_config_option` to
/// `<log>.sets` as `id=value`.
const AGENT: &str = r#"
n=0 prompt=
n=0 prompt= model=m/think effort=low
printf 'start\n' >> "$1.methods"
update() {
printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"%s","update":%s}}\n' "$1" "$2"
@ -799,6 +947,16 @@ ended() {
bare() {
printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn"}}\n' "$1"
}
options() {
[ -n "$OPTIONS" ] || return 0
o="{\"id\":\"model\",\"name\":\"Model\",\"category\":\"model\",\"type\":\"select\",\"currentValue\":\"$model\",\"options\":[{\"value\":\"m/think\",\"name\":\"Think\"},{\"value\":\"m/plain\",\"name\":\"Plain\"}]}"
[ "$model" = m/think ] && o="$o,{\"id\":\"effort\",\"name\":\"Effort\",\"category\":\"thought_level\",\"type\":\"select\",\"currentValue\":\"$effort\",\"options\":[{\"value\":\"low\",\"name\":\"Low\"},{\"value\":\"high\",\"name\":\"High\"}]}"
printf ',"configOptions":[%s]' "$o"
}
result() {
o=$(options)
printf '{"jsonrpc":"2.0","id":%s,"result":{%s}}\n' "$1" "${o#,}"
}
while IFS= read -r line; do
id=${line#*\"id\":}; id=${id%%[,\}]*}
m=${line#*\"method\":\"}; m=${m%%\"*}
@ -809,11 +967,17 @@ while IFS= read -r line; do
printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":true}}}}\n' "$id" ;;
session/new)
n=$((n+1))
printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"s%s"}}\n' "$id" "$n"
printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"s%s"%s}}\n' "$id" "$n" "$(options)"
advertise "s$n" ;;
session/load)
printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id"
result "$id"
advertise "$sid" ;;
session/set_config_option)
cid=${line#*\"configId\":\"}; cid=${cid%%\"*}
val=${line#*\"value\":\"}; val=${val%%\"*}
printf '%s=%s\n' "$cid" "$val" >> "$1.sets"
case $cid in model) model=$val ;; effort) effort=$val ;; esac
result "$id" ;;
session/prompt)
printf '%s\n' "$line" >> "$1"
[ -n "$USED" ] && [ "$sid" = s1 ] && update "$sid" "{\"sessionUpdate\":\"usage_update\",\"used\":$USED,\"size\":1000}"
@ -1291,4 +1455,60 @@ done
);
assert_eq!(recorded(dir.path()), "s2");
}
fn choice(current: &str, values: &[&str]) -> Choice {
Choice {
current: current.to_owned(),
values: values.iter().map(|v| (*v).to_owned()).collect(),
}
}
/// Each `session/set_config_option` the agent was sent, as `id=value`.
fn sets(dir: &Path) -> Vec<String> {
std::fs::read_to_string(dir.join("prompts.sets"))
.unwrap_or_default()
.lines()
.map(str::to_owned)
.collect()
}
#[tokio::test]
async fn model_and_effort_are_set_and_effort_goes_with_a_model_without_it() {
let dir = tempfile::tempdir().unwrap();
let runtime = agent(dir.path(), "ok", &[("OPTIONS", "1")], policy(0));
let choices = runtime.choices().unwrap();
assert_eq!(choices.get(), SessionChoices::default());
let mut config = Config {
model: Some("haiku".into()),
effort: Some("high".into()),
..config(dir.path())
};
runtime.run(&config, "one", &NoopSink).await.unwrap();
// `haiku` is not on offer, so only the effort is set.
assert_eq!(sets(dir.path()), ["effort=high"]);
assert_eq!(
choices.get(),
SessionChoices {
model: Some(choice("m/think", &["m/think", "m/plain"])),
effort: Some(choice("high", &["low", "high"])),
}
);
config.model = Some("m/plain".into());
let done = runtime.run(&config, "two", &NoopSink).await.unwrap();
assert_eq!(sets(dir.path()), ["effort=high", "model=m/plain"]);
assert_eq!(
choices.get(),
SessionChoices {
model: Some(choice("m/plain", &["m/think", "m/plain"])),
effort: None,
}
);
assert_eq!(done.telemetry.model.as_deref(), Some("m/plain"));
// Values the session already holds are not set again.
runtime.run(&config, "three", &NoopSink).await.unwrap();
assert_eq!(sets(dir.path()).len(), 2);
}
}

View file

@ -212,6 +212,61 @@ pub(super) fn advertised_commands(update: &Value) -> Option<Vec<String>> {
)
}
/// One select config option a session advertises.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ConfigOption {
pub(super) id: String,
pub(super) category: Option<String>,
pub(super) current: String,
pub(super) values: Vec<String>,
}
/// The select options in `value`'s `configOptions`, as a session response, a
/// `session/set_config_option` response and a `config_option_update` carry
/// them; `None` if it has none. Grouped values are flattened; boolean options
/// are left out.
pub(super) fn config_options(value: &Value) -> Option<Vec<ConfigOption>> {
let options = value.get("configOptions")?.as_array()?;
Some(
options
.iter()
.filter_map(|option| {
let entries = option.get("options").and_then(Value::as_array);
let values = entries
.into_iter()
.flatten()
.flat_map(
|entry| match entry.get("options").and_then(Value::as_array) {
Some(group) => group.iter().collect(),
None => vec![entry],
},
)
.filter_map(|entry| entry.get("value").and_then(Value::as_str))
.map(str::to_owned)
.collect();
Some(ConfigOption {
id: option.get("id")?.as_str()?.to_owned(),
category: option
.get("category")
.and_then(Value::as_str)
.map(str::to_owned),
current: option.get("currentValue")?.as_str()?.to_owned(),
values,
})
})
.collect(),
)
}
/// The options a `config_option_update` sets, or `None` for any other
/// `update`. Each replaces the session's previous options.
pub(super) fn updated_config_options(update: &Value) -> Option<Vec<ConfigOption>> {
if update.get("sessionUpdate").and_then(Value::as_str) != Some("config_option_update") {
return None;
}
Some(config_options(update).unwrap_or_default())
}
/// Convert a claude `--mcp-config` document into ACP's `mcpServers` list.
/// Returns the list and the server names, in the same order.
pub(super) fn mcp_servers(config: &Value) -> (Vec<Value>, Vec<String>) {
@ -346,7 +401,9 @@ fn tool_result(id: &str, output: &str, is_error: bool) -> Value {
#[cfg(test)]
mod tests {
use super::{StreamMapper, canonical_tool_name, mcp_servers, session_model};
use super::{
ConfigOption, StreamMapper, canonical_tool_name, config_options, mcp_servers, session_model,
};
use serde_json::{Value, json};
fn mapper() -> StreamMapper {
@ -547,4 +604,31 @@ mod tests {
assert_eq!(session_model(&resp).as_deref(), Some("p/m-2"));
assert_eq!(session_model(&json!({})), None);
}
#[test]
fn config_options_flatten_groups_and_skip_booleans() {
let resp = json!({
"configOptions": [
{ "id": "model", "name": "Model", "category": "model", "type": "select",
"currentValue": "p/a",
"options": [
{ "group": "p", "name": "P", "options": [
{ "value": "p/a", "name": "A" }, { "value": "p/b", "name": "B" },
] },
{ "value": "q/c", "name": "C" },
] },
{ "id": "fast", "name": "Fast", "type": "boolean", "currentValue": true },
],
});
assert_eq!(
config_options(&resp),
Some(vec![ConfigOption {
id: "model".into(),
category: Some("model".into()),
current: "p/a".into(),
values: vec!["p/a".into(), "p/b".into(), "q/c".into()],
}])
);
assert_eq!(config_options(&json!({})), None);
}
}

View file

@ -4,7 +4,7 @@ use std::path::PathBuf;
use hive_claude::{CompactionPolicy, Config, InfiniteSession, Progress, SessionStore, Sink};
use crate::{Canceller, Result, Runtime};
use crate::{Canceller, Choices, Result, Runtime};
/// `claude --print` turns on a titled [`InfiniteSession`], which owns
/// resume-or-create and compaction.
@ -43,4 +43,8 @@ impl<P: CompactionPolicy> Runtime for ClaudeRuntime<P> {
fn canceller(&self) -> Option<Canceller> {
None
}
fn choices(&self) -> Option<Choices> {
None
}
}

View file

@ -21,7 +21,10 @@ mod acp;
mod claude;
mod spec;
pub use acp::{AcpError, AcpRuntime, Canceller, PermissionAsk, PermissionPolicy};
pub use acp::{
AcpError, AcpRuntime, Canceller, Choice, Choices, PermissionAsk, PermissionPolicy,
SessionChoices,
};
pub use claude::ClaudeRuntime;
pub use hive_claude::{
CompactionPolicy, Config, PercentPolicy, Progress, SessionStore, Sink, Telemetry, TokenUsage,
@ -57,6 +60,11 @@ pub trait Runtime {
/// `None` for a runtime without one: a claude turn is stopped by
/// signalling its `claude` process.
fn canceller(&self) -> Option<Canceller>;
/// A handle reading the model and effort the session lets the client
/// pick. `None` for a runtime whose session offers none to read: claude's
/// `--model` and `--effort` are passed through unchecked.
fn choices(&self) -> Option<Choices>;
}
/// The runtime an agent was configured with, chosen at startup from a
@ -94,6 +102,13 @@ impl<P: CompactionPolicy> Runtime for AgentRuntime<P> {
Self::Acp(r) => r.canceller(),
}
}
fn choices(&self) -> Option<Choices> {
match self {
Self::Claude(r) => r.choices(),
Self::Acp(r) => r.choices(),
}
}
}
/// Why a runtime operation did not complete.