Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
da8c42f
feat(agent): advertise an OpenRouter model catalog so switch_model works
Aug 5, 2026
f9dc62e
test(acp): guard that an OpenRouter catalog resolves a switch_model r…
Aug 5, 2026
d746e3b
feat(acp): per-turn model routing, opt-in via BUZZ_ROUTING_POLICY
Aug 5, 2026
1f44781
fix(agents): mount ModelPicker so switch_model is reachable from the UI
Aug 6, 2026
dd0da27
test(e2e): allow the mock to serve a populated get_agent_models catalog
Aug 6, 2026
c9fd8b4
chore(gitignore): ignore the Claude Code context tree
Aug 6, 2026
b735afb
feat(agents): read OpenCode's config file so its model is visible
Aug 6, 2026
f5f3273
test(e2e): cover the agent ModelPicker
Aug 6, 2026
a527632
feat(agents): routing policy table in the agent editor
Aug 6, 2026
9623c30
feat(acp): harness-class decline gate — data model + decision logic
Aug 7, 2026
5b5daf3
feat(acp): wire harness-class decline gate into ingress
Aug 8, 2026
8eb397e
chore(gitignore): stop tracking scheduled_tasks.lock runtime state
Aug 8, 2026
f7ba141
revert(acp): unwire harness decline gate at ingress per adversarial r…
Aug 8, 2026
33be98b
style(acp): satisfy rustfmt in routing decision tests
Aug 18, 2026
ffa7569
fix(agent): re-home the OpenRouter catalog fallback onto main's refactor
Aug 18, 2026
fd2c9d1
fix(agents): document the a11y suppressions on the model-control slot
Aug 18, 2026
3b9563d
fix(agents): let the preset-logo guard span known ACP runtimes
Aug 18, 2026
a482a20
refactor(desktop): split oversized files instead of raising the ratchet
Aug 18, 2026
356b662
Merge origin/main and fix three defects the merge exposed
Aug 18, 2026
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,14 @@ identity.key
# Claude Code worktrees
.claude/worktrees/

# Claude Code runtime state (machine-local locks, not durable)
.claude/scheduled_tasks.lock

# mesh-llm build cache
.cache/

# Helm dependency tarballs — regenerable from Chart.lock via `helm dependency build`
deploy/charts/*/charts/*.tgz

# Claude Code context
.claude_context_tree
44 changes: 44 additions & 0 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2750,6 +2750,50 @@ mod tests {
assert!(super::extract_model_config_options(&result).is_empty());
}

/// Regression guard for the OpenRouter model-catalog seam.
///
/// `switch_model` (kind:24200) resolves the requested model against the
/// `availableModels` a provider advertises in `session/new`. buzz-agent used to
/// advertise a single-entry catalog for every non-Databricks provider, so an
/// OpenRouter switch could never resolve. The payload below is the real shape
/// buzz-agent now returns for `BUZZ_AGENT_PROVIDER=openrouter` (trimmed): an
/// account-eligible, tools-capable slate.
#[test]
fn openrouter_catalog_resolves_a_switch_and_rejects_an_ineligible_model() {
let session_new = serde_json::json!({
"sessionId": "sess-openrouter",
"models": {
"currentModelId": "openai/gpt-5.6-luna",
"availableModels": [
{ "modelId": "openai/gpt-5.6-luna", "name": "OpenAI: GPT-5.6 Luna" },
{ "modelId": "openai/gpt-5.6-luna-pro", "name": "OpenAI: GPT-5.6 Luna Pro" },
{ "modelId": "z-ai/glm-5.2", "name": "Z.ai: GLM 5.2" },
{ "modelId": "deepseek/deepseek-v4-flash-0731", "name": "DeepSeek: DeepSeek V4 Flash 0731" },
]
}
});

// A model in the advertised catalog resolves to a live set_model switch.
let method = super::resolve_model_switch_method(&session_new, "z-ai/glm-5.2")
.expect("a catalog model must resolve");
match method {
super::ModelSwitchMethod::SetModel { model_id } => {
assert_eq!(model_id, "z-ai/glm-5.2");
}
other => panic!("expected SetModel, got {other:?}"),
}

// A model absent from the catalog must NOT resolve. gpt-5.6-terra is the
// real case: it exists in OpenRouter's global catalog but is not on this
// account's eligibility allowlist, so requesting it returns HTTP 404.
// Refusing it here turns that into an up-front unsupported_model rather
// than a confusing mid-request failure.
assert!(
super::resolve_model_switch_method(&session_new, "openai/gpt-5.6-terra").is_none(),
"a model outside the advertised catalog must not resolve"
);
}

#[test]
fn extract_model_state_returns_models_object() {
let result = serde_json::json!({
Expand Down
24 changes: 24 additions & 0 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,22 @@ pub(crate) fn normalize_agent_command_identity(command: &str) -> String {
.collect()
}

/// Canonical harness *class* for a spawn command — the coarse bucket a harness
/// dispatcher compares against, stable across binary-name variants.
/// UNWIRED (decline-gate caller reverted 2026-08-08); kept for the dispatcher.
#[allow(dead_code)]
pub(crate) fn harness_class(command: &str) -> String {
let id = normalize_agent_command_identity(command);
match id.as_str() {
"codex" | "codex-acp" => "codex".to_string(),
"opencode" | "opencode-acp" => "opencode".to_string(),
"claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" => {
"claude".to_string()
}
_ => id, // goose, buzz-agent, hermes, custom -> class == identity
}
}

fn default_agent_args(command: &str) -> Option<Vec<String>> {
match normalize_agent_command_identity(command).as_str() {
"goose" => Some(vec!["acp".to_string()]),
Expand Down Expand Up @@ -1675,6 +1691,14 @@ mod tests {
assert_eq!(normalize_agent_command_identity("///"), "");
}

#[test]
fn harness_class_folds_variants() {
assert_eq!(harness_class("codex-acp"), "codex");
assert_eq!(harness_class("/usr/local/bin/opencode"), "opencode");
assert_eq!(harness_class("claude-code-acp"), "claude");
assert_eq!(harness_class("goose"), "goose");
}

#[test]
fn default_agent_env_recognizes_hermes_identities() {
for command in [
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ mod pool;
mod pool_lifecycle;
mod queue;
mod relay;
mod routing;
mod setup_mode;
mod usage;

Expand Down
28 changes: 28 additions & 0 deletions crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1780,6 +1780,34 @@ pub async fn run_prompt_task(
Some(b) => PromptSource::Channel(b.channel_id),
None => PromptSource::Heartbeat,
};

// Per-turn model routing (opt-in via BUZZ_ROUTING_POLICY; see routing.rs).
//
// Deliberately does NOT override a live `switch_model`: if the operator (or
// the desktop ModelPicker) explicitly pinned a model for this agent,
// `model_overridden` is set and a router silently changing it would make the
// UI lie about what is running. Explicit human choice outranks the policy.
//
// The decision is expressed as `desired_model`, which the existing
// session-creation path validates against the agent's advertised catalog and
// applies — so a policy naming a model the provider does not offer degrades
// to the agent default with a warning rather than failing the turn.
if !agent.model_overridden {
if let Some(policy) = crate::routing::Policy::from_env() {
let routed_text = prompt_text.as_deref().unwrap_or_default();
if let Some(decision) = policy.decide(routed_text).await {
if agent.desired_model.as_deref() != Some(decision.model.as_str()) {
tracing::info!(
target: "acp::routing",
model = %decision.model,
reason = ?decision.reason,
"routing selected a model for this turn"
);
agent.desired_model = Some(decision.model);
}
}
}
}
let observer_channel_id = match &source {
PromptSource::Channel(channel_id) => Some(*channel_id),
PromptSource::Heartbeat => None,
Expand Down
Loading