diff --git a/config.toml.example b/config.toml.example index 7c280dc1f..b51321641 100644 --- a/config.toml.example +++ b/config.toml.example @@ -117,6 +117,9 @@ working_dir = "/home/agent" # args = ["acp", "--model", "auto", "--workspace", "/home/agent"] # working_dir = "/home/agent" # env = {} # Auth via: kubectl exec -it -- cursor-agent login +# # Default Cursor Composer to non-fast (requires OpenAB parameterizedModelPicker support): +# [agent.default_config_options] +# fast = "false" # [agent] # command = "hermes-acp" diff --git a/docs/config-reference.md b/docs/config-reference.md index 1e862e00a..44f4b11b2 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -107,6 +107,7 @@ The AI agent subprocess that OpenAB spawns to handle messages via ACP. | `working_dir` | string | `$HOME` | Working directory for the agent process. Optional — defaults to container's `$HOME`. | | `env` | map | `{}` | Extra environment variables (e.g. `{ OPENAI_API_KEY = "${OPENAI_API_KEY}" }`). | | `inherit_env` | string[] | `[]` | Env var names to inherit from the OAB process (e.g. vars injected via K8s `envFrom`). Keys in `env` take precedence. | +| `default_config_options` | map | `{}` | ACP session defaults applied after `session/new` via `session/set_config_option` (e.g. Cursor `fast = "false"` for non-fast Composer). Ignored when the agent does not advertise the option. | > **Default inherited vars:** After `env_clear()`, the agent always receives `HOME`, `PATH`, and `USER` (on Windows: `USERPROFILE`, `USERNAME`, `PATH`, `SystemRoot`, `SystemDrive`). Use `inherit_env` to pass additional vars beyond this baseline. diff --git a/docs/cursor.md b/docs/cursor.md index 842206e0a..bba1c0ac2 100644 --- a/docs/cursor.md +++ b/docs/cursor.md @@ -85,11 +85,28 @@ To specify a model, pass `--model` as an arg: ```toml [agent] # Override args (command defaults from OPENAB_AGENT_COMMAND="cursor-agent acp") -args = ["acp", "--model", "auto"] +args = ["acp", "--model", "auto", "--workspace", "/home/agent"] ``` In ACP mode, `--model` can be appended after `acp`. If omitted, the account default is used. +OpenAB advertises Cursor's `parameterizedModelPicker` capability during ACP +`initialize`, which exposes model parameters (e.g. Composer **Fast** on/off) as +separate session config options. In Discord: + +- `/models` — switch the base model (e.g. Composer 2.5) +- `/speed` — toggle Fast mode on/off for the current session + +To default new sessions to non-fast Composer without using `/speed` each time: + +```toml +[agent.default_config_options] +fast = "false" +``` + +After changing model or speed on an existing session, use `/reset` or start a new +thread for the change to take effect on the next `session/new`. + To verify which model is active, ask the agent "who are you" — the underlying model will typically self-identify (e.g. "I am Gemini, a large language model built by Google."). ## MCP Usage (ACP mode caveats) diff --git a/src/acp/connection.rs b/src/acp/connection.rs index 8df3451f4..c9c9cd1cc 100644 --- a/src/acp/connection.rs +++ b/src/acp/connection.rs @@ -10,7 +10,7 @@ use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader use tokio::process::{Child, ChildStdin}; use tokio::sync::{mpsc, oneshot, Mutex}; use tokio::task::JoinHandle; -use tracing::{debug, error, info, trace}; +use tracing::{debug, error, info, trace, warn}; /// Pick the most permissive selectable permission option from ACP options. fn pick_best_option(options: &[Value]) -> Option { @@ -257,6 +257,21 @@ pub(crate) async fn run_reader_loop( *sub = None; } +/// ACP `initialize` params. Advertises Cursor's parameterized model picker so +/// agents like cursor-agent expose model parameters (e.g. Composer Fast on/off) +/// as separate config options instead of collapsing to the fast default variant. +fn initialize_params() -> Value { + json!({ + "protocolVersion": 1, + "clientCapabilities": { + "_meta": { + "parameterizedModelPicker": true + } + }, + "clientInfo": {"name": "openab", "version": "0.1.0"}, + }) +} + impl AcpConnection { pub async fn spawn( command: &str, @@ -453,14 +468,7 @@ impl AcpConnection { pub async fn initialize(&mut self) -> Result<()> { let resp = self - .send_request( - "initialize", - Some(json!({ - "protocolVersion": 1, - "clientCapabilities": {}, - "clientInfo": {"name": "openab", "version": "0.1.0"}, - })), - ) + .send_request("initialize", Some(initialize_params())) .await?; let result = resp.result.as_ref(); @@ -474,6 +482,21 @@ impl AcpConnection { .and_then(|c| c.get("loadSession")) .and_then(|v| v.as_bool()) .unwrap_or(false); + + if let Some(methods) = result.and_then(|r| r.get("authMethods")).and_then(|m| m.as_array()) { + if let Some(method_id) = methods + .iter() + .find_map(|m| m.get("id").and_then(|id| id.as_str())) + { + self.send_request( + "authenticate", + Some(json!({ "methodId": method_id })), + ) + .await?; + info!(method_id, "agent authenticated"); + } + } + info!( agent = agent_name, load_session = self.supports_load_session, @@ -506,6 +529,36 @@ impl AcpConnection { Ok(session_id) } + /// Apply configured defaults after `session/new` when the agent advertises matching + /// config options (e.g. Cursor `fast = "false"` for non-fast Composer). + pub async fn apply_default_config_options( + &mut self, + defaults: &HashMap, + ) -> Result<()> { + if defaults.is_empty() { + return Ok(()); + } + + for (config_id, desired) in defaults { + let Some(opt) = self.config_options.iter().find(|o| o.id == *config_id) else { + continue; + }; + if opt.current_value == *desired { + continue; + } + if !opt.options.iter().any(|o| o.value == *desired) { + warn!( + config_id, + desired, "default_config_options value not offered by agent, skipping" + ); + continue; + } + self.set_config_option(config_id, desired).await?; + } + + Ok(()) + } + /// Set a config option (e.g. model, mode) via ACP session/set_config_option. /// Returns the updated list of all config options. pub async fn set_config_option( @@ -832,6 +885,17 @@ mod tests { assert!(!result.contains_key("OAB_TEST_NONEXISTENT_VAR_12345")); assert!(inherited.is_empty()); } + + #[test] + fn initialize_params_advertises_parameterized_model_picker() { + let params = super::initialize_params(); + assert_eq!( + params + .pointer("/clientCapabilities/_meta/parameterizedModelPicker") + .and_then(|v| v.as_bool()), + Some(true) + ); + } } #[cfg(test)] diff --git a/src/acp/pool.rs b/src/acp/pool.rs index d97397169..b20f5a3ce 100644 --- a/src/acp/pool.rs +++ b/src/acp/pool.rs @@ -259,6 +259,12 @@ impl SessionPool { if !resumed { new_conn.session_new(&effective_workdir).await?; + if let Err(e) = new_conn + .apply_default_config_options(&self.config.default_config_options) + .await + { + warn!(thread_id, error = %e, "failed to apply default ACP config options"); + } // Surface the reset banner both for restored sessions and for stale // live entries that died before we could recover a resumable // session id. In both cases the caller is continuing after an diff --git a/src/config.rs b/src/config.rs index 6912fe561..8ca214bb7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -472,6 +472,8 @@ struct AgentConfigRaw { working_dir: String, env: HashMap, inherit_env: Vec, + #[serde(default)] + default_config_options: HashMap, } impl Default for AgentConfigRaw { @@ -482,6 +484,7 @@ impl Default for AgentConfigRaw { working_dir: default_working_dir(), env: HashMap::new(), inherit_env: Vec::new(), + default_config_options: HashMap::new(), } } } @@ -493,6 +496,8 @@ pub struct AgentConfig { pub working_dir: String, pub env: HashMap, pub inherit_env: Vec, + /// ACP session defaults applied after `session/new` (e.g. Cursor `fast = "false"`). + pub default_config_options: HashMap, /// Whether the command was explicitly set in config (vs defaulted from env/fallback). pub command_explicit: bool, } @@ -505,6 +510,7 @@ impl Default for AgentConfig { working_dir: default_working_dir(), env: HashMap::new(), inherit_env: Vec::new(), + default_config_options: HashMap::new(), command_explicit: false, } } @@ -531,6 +537,7 @@ impl<'de> serde::Deserialize<'de> for AgentConfig { working_dir: raw.working_dir, env: raw.env, inherit_env: raw.inherit_env, + default_config_options: raw.default_config_options, command_explicit: cmd_explicit, }) } @@ -946,6 +953,7 @@ fn parse_config_inner(expanded: &str, source: &str) -> anyhow::Result { working_dir: config.agent.working_dir.clone(), env: config.agent.env.clone(), inherit_env: config.agent.inherit_env.clone(), + default_config_options: config.agent.default_config_options.clone(), command_explicit: true, // synthesized counts as explicit }; } @@ -1375,6 +1383,26 @@ runtime_arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/test" assert_eq!(ac.cancel_strategy, AgentCoreCancelStrategy::Stop); } + #[test] + fn agent_default_config_options_parsed() { + let toml = r#" +[discord] +bot_token = "t" + +[agent] +command = "cursor-agent" +args = ["acp"] + +[agent.default_config_options] +fast = "false" +"#; + let cfg = parse_config(toml, "test").unwrap(); + assert_eq!( + cfg.agent.default_config_options.get("fast").map(String::as_str), + Some("false") + ); + } + #[test] fn agentcore_rejects_invalid_arn() { let toml = r#" diff --git a/src/discord.rs b/src/discord.rs index ce6d658cf..d57b43de1 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -919,6 +919,8 @@ impl EventHandler for Handler { // Build the shared command list once. let commands = vec![ CreateCommand::new("models").description("Select the AI model for this session"), + CreateCommand::new("speed") + .description("Toggle Composer Fast mode (on/off) for this session"), CreateCommand::new("agents").description("Select the agent mode for this session"), CreateCommand::new("cancel").description("Cancel the current operation"), CreateCommand::new("cancel-all") @@ -1010,6 +1012,10 @@ impl EventHandler for Handler { self.handle_config_command(&ctx, &cmd, "model", "model") .await; } + Interaction::Command(cmd) if cmd.data.name == "speed" => { + self.handle_config_command(&ctx, &cmd, "model_config", "speed") + .await; + } Interaction::Command(cmd) if cmd.data.name == "agents" => { self.handle_config_command(&ctx, &cmd, "agent", "agent") .await; @@ -1682,7 +1688,7 @@ impl Handler { }; // Only allow known config categories. - if !matches!(category, "model" | "agent") { + if !matches!(category, "model" | "model_config" | "agent") { return; } diff --git a/src/dispatch.rs b/src/dispatch.rs index 97d5f25e3..90ec2d0c5 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -1195,6 +1195,7 @@ mod tests { working_dir: "/tmp".into(), env: std::collections::HashMap::new(), inherit_env: vec![], + default_config_options: std::collections::HashMap::new(), command_explicit: true, }; let pool = Arc::new(SessionPool::new(agent_cfg, 1));