Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pod> -- cursor-agent login
# # Default Cursor Composer to non-fast (requires OpenAB parameterizedModelPicker support):
# [agent.default_config_options]
# fast = "false"

# [agent]
# command = "hermes-acp"
Expand Down
1 change: 1 addition & 0 deletions docs/config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
19 changes: 18 additions & 1 deletion docs/cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
82 changes: 73 additions & 9 deletions src/acp/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
Expand Down Expand Up @@ -257,6 +257,21 @@ pub(crate) async fn run_reader_loop<R, W>(
*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,
Expand Down Expand Up @@ -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();
Expand All @@ -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,
Expand Down Expand Up @@ -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<String, String>,
) -> 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(
Expand Down Expand Up @@ -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)]
Expand Down
6 changes: 6 additions & 0 deletions src/acp/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,8 @@ struct AgentConfigRaw {
working_dir: String,
env: HashMap<String, String>,
inherit_env: Vec<String>,
#[serde(default)]
default_config_options: HashMap<String, String>,
}

impl Default for AgentConfigRaw {
Expand All @@ -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(),
}
}
}
Expand All @@ -493,6 +496,8 @@ pub struct AgentConfig {
pub working_dir: String,
pub env: HashMap<String, String>,
pub inherit_env: Vec<String>,
/// ACP session defaults applied after `session/new` (e.g. Cursor `fast = "false"`).
pub default_config_options: HashMap<String, String>,
/// Whether the command was explicitly set in config (vs defaulted from env/fallback).
pub command_explicit: bool,
}
Expand All @@ -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,
}
}
Expand All @@ -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,
})
}
Expand Down Expand Up @@ -946,6 +953,7 @@ fn parse_config_inner(expanded: &str, source: &str) -> anyhow::Result<Config> {
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
};
}
Expand Down Expand Up @@ -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#"
Expand Down
8 changes: 7 additions & 1 deletion src/discord.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1682,7 +1688,7 @@ impl Handler {
};

// Only allow known config categories.
if !matches!(category, "model" | "agent") {
if !matches!(category, "model" | "model_config" | "agent") {
return;
}

Expand Down
1 change: 1 addition & 0 deletions src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading