Skip to content

Commit 07baddd

Browse files
authored
Merge pull request #1313 from wgqqqqq/fix/multimodal-model-capabilities
fix: preserve multimodal model capabilities
2 parents bd70285 + adaff67 commit 07baddd

10 files changed

Lines changed: 289 additions & 98 deletions

File tree

‎src/crates/assembly/core/src/agentic/execution/execution_engine.rs‎

Lines changed: 65 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,49 @@ impl ExecutionEngine {
655655
.unwrap_or_else(|| "auto".to_string())
656656
}
657657

658+
async fn resolve_primary_model_context(
659+
model_id: &str,
660+
ai_client_model: &str,
661+
ai_client_provider: &str,
662+
unavailable_log_message: &str,
663+
) -> (String, bool) {
664+
let config_service = get_global_config_service().await.ok();
665+
if let Some(service) = config_service {
666+
let ai_config: crate::service::config::types::AIConfig =
667+
service.get_config(Some("ai")).await.unwrap_or_default();
668+
669+
let resolved_id = Self::resolve_configured_model_id(&ai_config, model_id);
670+
let model_cfg = ai_config
671+
.models
672+
.iter()
673+
.find(|m| m.id == resolved_id)
674+
.or_else(|| ai_config.models.iter().find(|m| m.name == resolved_id))
675+
.or_else(|| {
676+
ai_config
677+
.models
678+
.iter()
679+
.find(|m| m.model_name == resolved_id)
680+
})
681+
.or_else(|| {
682+
ai_config.models.iter().find(|m| {
683+
m.model_name == ai_client_model && m.provider == ai_client_provider
684+
})
685+
});
686+
687+
let supports = model_cfg.is_some_and(|m| {
688+
m.capabilities
689+
.iter()
690+
.any(|cap| matches!(cap, ModelCapability::ImageUnderstanding))
691+
|| matches!(m.category, ModelCategory::Multimodal)
692+
});
693+
694+
(resolved_id, supports)
695+
} else {
696+
warn!("{}", unavailable_log_message);
697+
(model_id.to_string(), false)
698+
}
699+
}
700+
658701
async fn build_tool_listing_sections(
659702
manifest: &ResolvedToolManifest,
660703
tool_context: &crate::agentic::tools::framework::ToolUseContext,
@@ -1527,46 +1570,14 @@ impl ExecutionEngine {
15271570
))
15281571
})?;
15291572

1530-
let (resolved_primary_model_id, primary_supports_image_understanding) = {
1531-
let config_service = get_global_config_service().await.ok();
1532-
if let Some(service) = config_service {
1533-
let ai_config: crate::service::config::types::AIConfig =
1534-
service.get_config(Some("ai")).await.unwrap_or_default();
1535-
1536-
let resolved_id = Self::resolve_configured_model_id(&ai_config, &model_id);
1537-
let model_cfg = ai_config
1538-
.models
1539-
.iter()
1540-
.find(|m| m.id == resolved_id)
1541-
.or_else(|| ai_config.models.iter().find(|m| m.name == resolved_id))
1542-
.or_else(|| {
1543-
ai_config
1544-
.models
1545-
.iter()
1546-
.find(|m| m.model_name == resolved_id)
1547-
})
1548-
.or_else(|| {
1549-
ai_config.models.iter().find(|m| {
1550-
m.model_name == ai_client.config.model
1551-
&& m.provider == ai_client.config.format
1552-
})
1553-
});
1554-
1555-
let supports = model_cfg.is_some_and(|m| {
1556-
m.capabilities
1557-
.iter()
1558-
.any(|cap| matches!(cap, ModelCapability::ImageUnderstanding))
1559-
|| matches!(m.category, ModelCategory::Multimodal)
1560-
});
1561-
1562-
(resolved_id, supports)
1563-
} else {
1564-
warn!(
1565-
"Config service unavailable, assuming compression model is text-only for image input gating"
1566-
);
1567-
(model_id.clone(), false)
1568-
}
1569-
};
1573+
let (resolved_primary_model_id, primary_supports_image_understanding) =
1574+
Self::resolve_primary_model_context(
1575+
&model_id,
1576+
&ai_client.config.model,
1577+
&ai_client.config.format,
1578+
"Config service unavailable, assuming compression model is text-only for image input gating",
1579+
)
1580+
.await;
15701581

15711582
let model_capability_profile = ModelCapabilityProfile::from_resolved_model(
15721583
&resolved_primary_model_id,
@@ -1602,6 +1613,9 @@ impl ExecutionEngine {
16021613
&context.agent_type,
16031614
context.workspace.as_ref(),
16041615
context.workspace_services.as_ref(),
1616+
Some(&resolved_primary_model_id),
1617+
Some(&ai_client.config.model),
1618+
Some(&ai_client.config.format),
16051619
primary_supports_image_understanding,
16061620
&tool_manifest_context_vars,
16071621
);
@@ -2198,47 +2212,14 @@ impl ExecutionEngine {
21982212
})?;
21992213

22002214
// Primary model vision capability (tools + system prompt appendix; also used below for API message stripping).
2201-
let (resolved_primary_model_id, primary_supports_image_understanding) = {
2202-
let config_service = get_global_config_service().await.ok();
2203-
if let Some(service) = config_service {
2204-
let ai_config: crate::service::config::types::AIConfig =
2205-
service.get_config(Some("ai")).await.unwrap_or_default();
2206-
2207-
let resolved_id = Self::resolve_configured_model_id(&ai_config, &model_id);
2208-
2209-
let model_cfg = ai_config
2210-
.models
2211-
.iter()
2212-
.find(|m| m.id == resolved_id)
2213-
.or_else(|| ai_config.models.iter().find(|m| m.name == resolved_id))
2214-
.or_else(|| {
2215-
ai_config
2216-
.models
2217-
.iter()
2218-
.find(|m| m.model_name == resolved_id)
2219-
})
2220-
.or_else(|| {
2221-
ai_config.models.iter().find(|m| {
2222-
m.model_name == ai_client.config.model
2223-
&& m.provider == ai_client.config.format
2224-
})
2225-
});
2226-
2227-
let supports = model_cfg.is_some_and(|m| {
2228-
m.capabilities
2229-
.iter()
2230-
.any(|cap| matches!(cap, ModelCapability::ImageUnderstanding))
2231-
|| matches!(m.category, ModelCategory::Multimodal)
2232-
});
2233-
2234-
(resolved_id, supports)
2235-
} else {
2236-
warn!(
2237-
"Config service unavailable, assuming primary model is text-only for image input gating"
2238-
);
2239-
(model_id.clone(), false)
2240-
}
2241-
};
2215+
let (resolved_primary_model_id, primary_supports_image_understanding) =
2216+
Self::resolve_primary_model_context(
2217+
&model_id,
2218+
&ai_client.config.model,
2219+
&ai_client.config.format,
2220+
"Config service unavailable, assuming primary model is text-only for image input gating",
2221+
)
2222+
.await;
22422223

22432224
let model_context_window = ai_client.config.context_window as usize;
22442225
let session_max_tokens = session.config.max_context_tokens;
@@ -2296,6 +2277,9 @@ impl ExecutionEngine {
22962277
&agent_type,
22972278
context.workspace.as_ref(),
22982279
context.workspace_services.as_ref(),
2280+
Some(&resolved_primary_model_id),
2281+
Some(&ai_client.config.model),
2282+
Some(&ai_client.config.format),
22992283
primary_supports_image_understanding,
23002284
&tool_manifest_context_vars,
23012285
);

‎src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ pub async fn resolve_skill_agent_snapshot(
5151
agent_type,
5252
workspace,
5353
workspace_services,
54+
None,
55+
None,
56+
None,
5457
true,
5558
context_vars,
5659
);

‎src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,18 @@ mod tests {
239239
}
240240
}
241241

242+
fn multimodal_anthropic_tool_context(agent_type: Option<&str>) -> ToolUseContext {
243+
let mut context = tool_context(agent_type);
244+
context.custom_data.insert(
245+
"primary_model_supports_image_understanding".to_string(),
246+
json!(true),
247+
);
248+
context
249+
.custom_data
250+
.insert("primary_model_provider".to_string(), json!("anthropic"));
251+
context
252+
}
253+
242254
fn context_without_agent_type() -> ToolUseContext {
243255
tool_context(None)
244256
}
@@ -483,6 +495,24 @@ mod tests {
483495
.any(|tool| tool.name == GET_TOOL_SPEC_TOOL_NAME));
484496
}
485497

498+
#[tokio::test]
499+
async fn product_manifest_keeps_view_image_for_multimodal_anthropic_context() {
500+
let allowed_tools = vec!["Read".to_string(), "view_image".to_string()];
501+
502+
let manifest = resolve_product_resolved_tool_manifest(
503+
&allowed_tools,
504+
&AgentToolPolicyOverrides::default(),
505+
&multimodal_anthropic_tool_context(Some("test-agent")),
506+
)
507+
.await;
508+
509+
assert_eq!(manifest.allowed_tool_names, allowed_tools);
510+
assert!(manifest
511+
.tool_definitions
512+
.iter()
513+
.any(|tool| tool.name == "view_image"));
514+
}
515+
486516
#[tokio::test]
487517
async fn product_manifest_snapshot_preserves_collapsed_tool_discovery_contract() {
488518
let allowed_tools = vec![

‎src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,10 +224,31 @@ pub(crate) fn build_tool_description_context(
224224
agent_type: &str,
225225
workspace: Option<&WorkspaceBinding>,
226226
workspace_services: Option<&WorkspaceServices>,
227+
primary_model_id: Option<&str>,
228+
primary_model_name: Option<&str>,
229+
primary_model_provider: Option<&str>,
227230
primary_supports_image_understanding: bool,
228231
context_vars: &HashMap<String, String>,
229232
) -> ToolUseContext {
230233
let mut custom_data = HashMap::new();
234+
if let Some(primary_model_id) = primary_model_id {
235+
custom_data.insert(
236+
"primary_model_id".to_string(),
237+
Value::String(primary_model_id.to_string()),
238+
);
239+
}
240+
if let Some(primary_model_name) = primary_model_name {
241+
custom_data.insert(
242+
"primary_model_name".to_string(),
243+
Value::String(primary_model_name.to_string()),
244+
);
245+
}
246+
if let Some(primary_model_provider) = primary_model_provider {
247+
custom_data.insert(
248+
"primary_model_provider".to_string(),
249+
Value::String(primary_model_provider.to_string()),
250+
);
251+
}
231252
custom_data.insert(
232253
"primary_model_supports_image_understanding".to_string(),
233254
Value::Bool(primary_supports_image_understanding),
@@ -1197,7 +1218,16 @@ mod context_builder_tests {
11971218
"false".to_string(),
11981219
);
11991220

1200-
let context = build_tool_description_context("coding", None, None, true, &context_vars);
1221+
let context = build_tool_description_context(
1222+
"coding",
1223+
None,
1224+
None,
1225+
Some("model_1"),
1226+
Some("vision-model"),
1227+
Some("anthropic"),
1228+
true,
1229+
&context_vars,
1230+
);
12011231

12021232
assert_eq!(context.agent_type.as_deref(), Some("coding"));
12031233
assert!(context.tool_call_id.is_none());
@@ -1212,6 +1242,15 @@ mod context_builder_tests {
12121242
context.custom_data["primary_model_supports_image_understanding"],
12131243
json!("false")
12141244
);
1245+
assert_eq!(context.custom_data["primary_model_id"], json!("model_1"));
1246+
assert_eq!(
1247+
context.custom_data["primary_model_name"],
1248+
json!("vision-model")
1249+
);
1250+
assert_eq!(
1251+
context.custom_data["primary_model_provider"],
1252+
json!("anthropic")
1253+
);
12151254
}
12161255
}
12171256

‎src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx‎

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import {
66
AIModelConfig as AIModelConfigType,
77
ProxyConfig,
88
ModelCategory,
9-
ModelCapability,
109
ReasoningMode
1110
} from '../types';
1211
import { configManager } from '../services/ConfigManager';
12+
import { getCapabilitiesByCategory, resolveModelCategory } from '../services/modelCategory';
1313
import { PROVIDER_TEMPLATES, getModelDisplayName, getProviderDisplayName, getProviderTemplateId } from '../services/modelConfigs';
1414
import { DEFAULT_REASONING_MODE, getEffectiveReasoningMode, supportsAnthropicAdaptive, supportsAnthropicReasoning, supportsAnthropicThinkingBudget, supportsDeepSeekReasoningEffort, supportsResponsesReasoning } from '../utils/reasoning';
1515
import { aiApi, systemAPI } from '@/infrastructure/api';
@@ -233,15 +233,6 @@ function dedupeSelectedModelDraftsByModelName(drafts: SelectedModelDraft[]): Sel
233233
return out;
234234
}
235235

236-
function getCapabilitiesByCategory(category: ModelCategory): ModelCapability[] {
237-
switch (category) {
238-
case 'general_chat':
239-
case 'multimodal':
240-
default:
241-
return ['text_chat', 'function_calling'];
242-
}
243-
}
244-
245236
/**
246237
* Compute the stored request URL from a base URL and provider format.
247238
* For gemini, stores the bare base (no /v1beta/models/... suffix) —
@@ -1047,8 +1038,18 @@ const AIModelConfig: React.FC = () => {
10471038
enabled: editingConfig.enabled ?? true,
10481039
context_window: draft.contextWindow,
10491040
max_tokens: draft.maxTokens,
1050-
category: draft.category,
1051-
capabilities: getCapabilitiesByCategory(draft.category),
1041+
category: resolveModelCategory(
1042+
draft.modelName,
1043+
draft.category,
1044+
editingConfig.provider || 'openai'
1045+
),
1046+
capabilities: getCapabilitiesByCategory(
1047+
resolveModelCategory(
1048+
draft.modelName,
1049+
draft.category,
1050+
editingConfig.provider || 'openai'
1051+
)
1052+
),
10521053
recommended_for: editingConfig.recommended_for || [],
10531054
metadata: {
10541055
...(editingConfig.metadata || {}),

0 commit comments

Comments
 (0)