fix(agent): default ACP agents to basic capability tier

This commit is contained in:
Fini 2026-07-30 08:57:17 +08:00
parent 6932081068
commit ee44d9b2c0
7 changed files with 222 additions and 26 deletions

View file

@ -1,16 +1,24 @@
use op_editor_core::EditorState;
use op_orchestrator::{AppendContext, DesignRequest};
/// Resolve the selected chat model's id for the orchestrator. Only
/// built-in (API-key) agents expose a concrete model id; CLI/ACP agents
/// pick their own model internally and yield `None` (the CLI-side
/// selection rides `ChatProviderLlmClient::with_model` instead). The id
/// feeds model-aware orchestrator policy — tier-gated skill filtering,
/// the element-manifest routing gate, and the M3 thinking policy — and
/// matches the configuration the ab-v9 benchmarks ran with (op-smoke
/// has always passed `OPENPENCIL_ORCHESTRATOR_MODEL` through).
fn selected_builtin_model(state: &EditorState) -> Option<String> {
/// Resolve the selected chat model's capability id for the orchestrator.
/// Built-in (API-key) agents expose their concrete model id. ACP entries
/// preserve their `acp:<id>` catalog identity so the model-profile resolver
/// can choose its conservative weak-agent default instead of treating a
/// missing id as Full tier. The ACP marker is not a transport model override:
/// `selected_cli_model_id` still yields `None` for ACP providers.
///
/// Fixed CLI agents keep choosing their own model internally and yield `None`
/// here (the CLI-side selection rides `ChatProviderLlmClient::with_model`
/// instead). The returned id feeds model-aware orchestrator policy —
/// tier-gated skill filtering, the element-manifest routing gate, and the M3
/// thinking policy — and matches the configuration the ab-v9 benchmarks ran
/// with (op-smoke has always passed `OPENPENCIL_ORCHESTRATOR_MODEL` through).
fn selected_orchestrator_model(state: &EditorState) -> Option<String> {
let entry = state.chat.selected_model_entry()?;
if entry.acp_agent_id().is_some() {
return Some(entry.value.clone());
}
let id = entry.builtin_provider_id.as_deref()?;
state
.editor_ui
@ -29,7 +37,7 @@ pub(crate) fn build_design_request(
) -> DesignRequest {
DesignRequest {
prompt,
model: selected_builtin_model(state),
model: selected_orchestrator_model(state),
provider: None,
design_md: state.doc.design_md.clone(),
// Detected by `chat_intent::detect_append_intent` when the
@ -160,4 +168,19 @@ mod tests {
// M3 thinking policy — must match the agent the session will call.
assert_eq!(req.model.as_deref(), Some("MiniMax-M3"));
}
#[test]
fn selected_acp_agent_reaches_the_orchestrator_as_basic_tier() {
let mut state = EditorState::new();
state.chat.available_models = vec![ModelEntry::acp("custom/vendor", "Custom ACP")];
state.chat.selected_model = 0;
let req = build_design_request("draw a dashboard".into(), &state, None);
assert_eq!(req.model.as_deref(), Some("acp:custom/vendor"));
assert_eq!(
op_orchestrator::resolve_model_profile(req.model.as_deref().unwrap()).tier,
op_orchestrator::ModelTier::Basic
);
}
}

View file

@ -26,6 +26,23 @@ use super::ChatSession;
mod chat_design_request;
use chat_design_request::build_design_request;
/// Build the model-aware request while the live chat selection is still
/// attached, then take the narrowed worker snapshot.
///
/// `narrowed_snapshot` deliberately detaches `EditorState::chat`; reversing
/// these two operations therefore erases the selected builtin/ACP identity
/// and makes the orchestrator resolve `model=None` as Full tier.
fn prepare_design_request_and_snapshot(
host: &mut WidgetHostNative,
prompt: String,
append_context: Option<op_orchestrator::AppendContext>,
) -> (op_orchestrator::DesignRequest, EditorState) {
let request = build_design_request(prompt, host.editor_state(), append_context);
let initial_state =
op_editor_core::request_snapshot::narrowed_snapshot(host.editor_state_mut());
(request, initial_state)
}
// Design-agent-loop helpers (flag gate + provider builder + turn launcher)
// split out at the 800-line cap; see module docs there.
#[path = "chat_session_launch_design.rs"]
@ -115,14 +132,17 @@ pub fn launch_if_pending(
host.editor_state(),
&effective_user_text,
);
let (request, initial_state) = prepare_design_request_and_snapshot(
host,
effective_user_text.clone(),
append_context,
);
// Narrowed clone — this becomes the design worker's
// `RemoteDocSink` mirror, which is only ever read through
// `DocSink::state()` (`active_children` / `doc` / `components`).
// See `op_editor_core::request_snapshot` for the field audit.
let initial_state =
op_editor_core::request_snapshot::narrowed_snapshot(host.editor_state_mut());
let request =
build_design_request(effective_user_text.clone(), &initial_state, append_context);
// The request above must be built first because that snapshot
// intentionally detaches chat/model-selection state.
// Persist the request onto the turn's assistant bubble (already
// pushed by `begin_send`) BEFORE it moves into the worker — the
// manual per-subtask "Retry" button needs it to re-run a failed
@ -420,14 +440,13 @@ fn launch_cli_standard_turn(
let system_prompt = build_chat_system_prompt(state, user_text);
let modify_plan = op_host_services::chat_intent::build_modify_plan(state, user_text);
let append_context = op_host_services::chat_intent::detect_append_intent(state, user_text);
let (design_request, initial_state) =
prepare_design_request_and_snapshot(host, user_text.to_string(), append_context);
// Narrowed clone — `CliTurnPlan::initial_state` ends up as the design
// worker's `RemoteDocSink` mirror, read only through `DocSink::state()`.
// See `op_editor_core::request_snapshot` for the field audit. Takes the
// mutable borrow, so it must come after the last read of `state`.
let initial_state =
op_editor_core::request_snapshot::narrowed_snapshot(host.editor_state_mut());
let design_request =
build_design_request(user_text.to_string(), &initial_state, append_context);
// See `op_editor_core::request_snapshot` for the field audit. Preparation
// takes the mutable borrow, so it must come after the last read of `state`;
// it builds the model-aware request before detaching chat for the snapshot.
// Same stash as the builtin/design-intent path above — this turn may or
// may not actually classify as `DesignIntent::New` on the worker (the
// classifier runs async), but setting it unconditionally is harmless:

View file

@ -2,7 +2,10 @@
//! 800-line-per-file ceiling (mirrors `chat_session_launch_selection_tests.rs`).
use super::*;
use op_editor_core::pen_node_ext::PenNodeExt;
use op_editor_core::{
pen_node_ext::PenNodeExt, AgentProvider, BuiltinAgentConfig, BuiltinAgentKind,
BuiltinAgentPresetKey, ModelEntry,
};
fn frame(
id: &str,
@ -97,6 +100,86 @@ fn stash_design_request_for_retry_writes_json_onto_the_last_message() {
assert_eq!(restored.prompt, "design a login page");
}
#[test]
fn design_launch_preparation_captures_acp_tier_before_detaching_chat() {
let mut host = WidgetHostNative::new();
host.editor_state_mut().chat.available_models =
vec![ModelEntry::acp("custom/vendor", "Custom ACP")];
host.editor_state_mut().chat.selected_model = 0;
let expected_revision = host.editor_state().document_revision();
let expected_root_count = host.editor_state().active_children().len();
let (request, initial_state) =
prepare_design_request_and_snapshot(&mut host, "draw a dashboard".into(), None);
assert_eq!(request.model.as_deref(), Some("acp:custom/vendor"));
assert_eq!(
op_orchestrator::resolve_model_profile(request.model.as_deref().unwrap()).tier,
op_orchestrator::ModelTier::Basic
);
assert!(
initial_state.chat.selected_model_entry().is_none(),
"the worker snapshot must stay narrowed"
);
assert_eq!(initial_state.document_revision(), expected_revision);
assert_eq!(initial_state.active_children().len(), expected_root_count);
assert_eq!(
host.editor_state()
.chat
.selected_model_entry()
.and_then(ModelEntry::acp_agent_id),
Some("custom/vendor"),
"snapshot preparation must restore the live chat selection"
);
}
#[test]
fn design_launch_preparation_captures_builtin_model_before_detaching_chat() {
let mut host = WidgetHostNative::new();
host.editor_state_mut()
.editor_ui
.agent_settings
.builtin_agents
.push(BuiltinAgentConfig {
id: "builtin-1".into(),
preset: BuiltinAgentPresetKey::Custom,
display_name: "MiniMax".into(),
kind: BuiltinAgentKind::OpenAiCompat,
api_key: "sk-test".into(),
model: "MiniMax-M3".into(),
base_url: "http://localhost:9".into(),
enabled: true,
});
host.editor_state_mut().chat.available_models = vec![ModelEntry::builtin(
AgentProvider::ClaudeCode,
"builtin-1",
"builtin:builtin-1:MiniMax-M3",
"MiniMax M3",
)];
host.editor_state_mut().chat.selected_model = 0;
let (request, initial_state) =
prepare_design_request_and_snapshot(&mut host, "draw a dashboard".into(), None);
assert_eq!(request.model.as_deref(), Some("MiniMax-M3"));
assert_eq!(
op_orchestrator::resolve_model_profile(request.model.as_deref().unwrap()).tier,
op_orchestrator::ModelTier::Full
);
assert!(
initial_state.chat.selected_model_entry().is_none(),
"the worker snapshot must stay narrowed"
);
assert_eq!(
host.editor_state()
.chat
.selected_model_entry()
.and_then(|entry| entry.builtin_provider_id.as_deref()),
Some("builtin-1"),
"snapshot preparation must restore the live builtin selection"
);
}
/// Regression lock for a real bug a user hit: the CLI-standard route
/// (`launch_cli_standard_turn`, reached whenever no builtin/ACP model is
/// selected — the common case) never stashed `design_request_json_for_retry`

View file

@ -98,6 +98,7 @@ impl LlmClient for ChatProviderLlmClient {
let m3_keeps_thinking = req
.model
.as_deref()
.filter(|model| !op_orchestrator::is_acp_capability_marker(model))
.map(|m| m.to_ascii_lowercase().contains("minimax-m3"))
.unwrap_or(false);
let chat_req = ChatRequest {
@ -227,6 +228,14 @@ mod tests {
let unknown = call_with_model(None);
assert_eq!(unknown.thinking, ThinkingMode::Disabled);
let acp_marker = call_with_model(Some("acp:minimax-m3-wrapper"));
assert_eq!(acp_marker.thinking, ThinkingMode::Disabled);
assert_eq!(acp_marker.max_output_tokens, 16384);
assert_eq!(
acp_marker.model, None,
"ACP capability identity must not become a transport model"
);
}
// ── Provider-shape agnosticism ───────────────────────────────────────

View file

@ -153,7 +153,7 @@ impl ChatVisionLlmClient {
/// Attach the vision model id to every request this client issues.
pub fn with_model(mut self, model: Option<String>) -> Self {
self.model = model;
self.model = Self::transport_model(model);
self
}
@ -171,6 +171,13 @@ impl ChatVisionLlmClient {
data,
})
}
/// Keep ACP catalog identities inside orchestrator capability policy.
/// They name an agent, not a provider model, and therefore must collapse
/// to the provider default at this transport boundary.
fn transport_model(model: Option<String>) -> Option<String> {
model.filter(|id| !op_orchestrator::is_acp_capability_marker(id))
}
}
impl VisionLlmClient for ChatVisionLlmClient {
@ -207,7 +214,8 @@ impl VisionLlmClient for ChatVisionLlmClient {
thinking: ThinkingMode::Disabled,
effort: EffortLevel::Low,
attachments: vec![attachment],
model: self.model.clone().or_else(|| req.model.clone()),
model: Self::transport_model(self.model.clone())
.or_else(|| Self::transport_model(req.model.clone())),
};
// `provider.send` is a blocking delta iterator (the same shape the
@ -401,6 +409,29 @@ mod tests {
assert_eq!(r.model.as_deref(), Some("vision-model"));
}
#[test]
fn chat_vision_client_does_not_forward_acp_capability_marker_as_model() {
let seen = Arc::new(Mutex::new(Vec::new()));
let provider = Arc::new(RecordingVisionProvider {
seen: seen.clone(),
reply: r#"{"issues":[],"fixes":[],"qualityScore":9}"#.into(),
});
let client =
ChatVisionLlmClient::new(provider).with_model(Some("acp:custom/vendor".to_string()));
let mut request = vision_req(&b64_png());
request.model = Some("acp:custom/vendor".to_string());
let response = client.validate(request);
assert!(matches!(response, VisionResponse::Text(_)));
let requests = seen.lock().unwrap();
assert_eq!(
requests.first().expect("provider was called").model,
None,
"ACP catalog identity is a capability marker, not a transport model"
);
}
/// A non-base64 screenshot string can't drive a vision call → Skipped.
#[test]
fn chat_vision_client_skips_on_bad_base64() {

View file

@ -127,7 +127,7 @@ pub use design_type::{detect_design_type, DesignType, DesignTypePreset};
pub use intent::classify_intent;
pub use loop_finalize::{apply_loop_finalize, apply_loop_finalize_counted};
pub use mobile_reflow::repair_mobile_trailing_nav_reflow;
pub use model_profile::{resolve_model_profile, ModelProfile, ModelTier};
pub use model_profile::{is_acp_capability_marker, resolve_model_profile, ModelProfile, ModelTier};
pub use prompt::build_orchestrator_prompt;
pub use repair_summary::{CheckCategory, RepairSummary};
pub use run::Orchestrator;

View file

@ -48,6 +48,26 @@ const DEFAULT_PROFILE: ModelProfile = ModelProfile {
label: "Unknown model",
};
/// ACP agents do not expose their backing model to OpenPencil. Treat their
/// catalog identity conservatively instead of promoting a missing model id to
/// the Full-tier default.
const ACP_PROFILE: ModelProfile = ModelProfile {
tier: ModelTier::Basic,
thinking_disabled: true,
timeout_multiplier: 1.0,
label: "ACP agent",
};
/// Whether `model_id` is the catalog identity of an ACP agent rather than a
/// concrete provider model. Hosts may carry this marker through
/// [`crate::DesignRequest`] for capability policy, but must not forward it as a
/// transport model override.
pub fn is_acp_capability_marker(model_id: &str) -> bool {
model_id
.get(..4)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("acp:"))
}
/// 模型表 —— verbatim 移植自 `model-profiles.ts:22-95`,首个命中胜出。
const MODEL_PROFILES: &[Entry] = &[
// Full tier
@ -215,8 +235,10 @@ const fn e(matcher: Match, tier: ModelTier, thinking_disabled: bool, label: &'st
}
}
/// 解析模型 id → profile。strip `provider/` 前缀 → 小写 → 首个命中。
/// 空 id → 强制 `Full`(TS 行为);无命中 → `DEFAULT_PROFILE`。
/// Resolve a model id to its capability profile. ACP catalog ids use the
/// conservative `Basic` default. Other ids strip a `provider/` prefix, then
/// match the lower-cased model table. An empty id keeps the legacy forced
/// `Full` behavior; an unmatched non-empty id uses [`DEFAULT_PROFILE`].
pub fn resolve_model_profile(model_id: &str) -> ModelProfile {
if model_id.is_empty() {
return ModelProfile {
@ -226,6 +248,11 @@ pub fn resolve_model_profile(model_id: &str) -> ModelProfile {
label: "Default (no model)",
};
}
// Check before stripping a provider prefix: ACP ids are opaque and may
// themselves contain `/` (for example `acp:vendor/custom-agent`).
if is_acp_capability_marker(model_id) {
return ACP_PROFILE;
}
let normalized = match model_id.find('/') {
Some(i) => &model_id[i + 1..],
None => model_id,
@ -284,6 +311,10 @@ mod tests {
assert_eq!(resolve_model_profile("minimax-01").tier, ModelTier::Basic);
assert_eq!(resolve_model_profile("glm-4-plus").tier, ModelTier::Basic);
assert_eq!(resolve_model_profile("qwen-max").tier, ModelTier::Basic);
assert_eq!(
resolve_model_profile("acp:vendor/custom-agent").tier,
ModelTier::Basic
);
}
#[test]