diff --git a/src-tauri/src/acp/delegation/broker.rs b/src-tauri/src/acp/delegation/broker.rs index f62029a986..0b91296048 100644 --- a/src-tauri/src/acp/delegation/broker.rs +++ b/src-tauri/src/acp/delegation/broker.rs @@ -824,6 +824,22 @@ fn report_from_outcome( } } +/// Overlay per-call session knobs on the Settings defaults. Per-call keys +/// win; a first-class `model` wins last so it beats `config.model`. +fn merge_per_call_config( + mut defaults: BTreeMap, + per_call: &BTreeMap, + model: Option, +) -> BTreeMap { + for (key, value) in per_call { + defaults.insert(key.clone(), value.clone()); + } + if let Some(model) = model { + defaults.insert("model".into(), model); + } + defaults +} + /// Build a `Failed`/`Canceled` report for a setup error (no task id — setup /// failed before/around registration, so the LLM has no task to track). fn report_err( @@ -2468,6 +2484,11 @@ impl DelegationBroker { .get(&req.agent_type) .map(|d: &AgentDelegationDefaults| (d.mode_id.clone(), d.config_values.clone())) .unwrap_or((None, BTreeMap::new())); + // Per-call `config` / `model` win over the Settings defaults, same + // idea as a parent pinning one child without changing global knobs. + // First-class `model` is applied last so it beats `config.model`. + let preferred_config_values = + merge_per_call_config(preferred_config_values, &req.config_values, req.model.clone()); // Checkpoint #1 (opportunistic): if a parent cancel already landed // during the claim/depth phase, bail before spawning a child the parent // has abandoned. No child exists yet, so there's nothing to tear down. @@ -4656,6 +4677,8 @@ mod tests { task: "do x".into(), working_dir: None, requested_working_dir: None, + model: None, + config_values: BTreeMap::new(), external_handle: None, } } @@ -5416,6 +5439,85 @@ mod tests { assert!(args[0].preferred_config_values.is_empty()); } + #[tokio::test] + async fn per_call_model_overrides_configured_model() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("child-1".into())).await; + mock.queue_send(Err(SpawnerError::Send("stop after spawn".into()))) + .await; + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + + let mut claude_cfg = BTreeMap::new(); + claude_cfg.insert("model".into(), "claude-sonnet-4-5".into()); + claude_cfg.insert("effort".into(), "low".into()); + let mut agent_defaults = BTreeMap::new(); + agent_defaults.insert( + AgentType::ClaudeCode, + AgentDelegationDefaults { + mode_id: None, + config_values: claude_cfg, + }, + ); + broker + .set_config(DelegationConfig { + enabled: true, + depth_limit: 8, + agent_defaults, + ..DelegationConfig::default() + }) + .await; + + let mut req = request(1, "pt-1"); + req.model = Some("claude-opus-4-6".into()); + req.config_values.insert("effort".into(), "high".into()); + let _ = broker.handle_request(req).await; + + let args = mock.spawn_args.lock().await; + assert_eq!(args.len(), 1); + assert_eq!( + args[0].preferred_config_values.get("model").map(String::as_str), + Some("claude-opus-4-6") + ); + assert_eq!( + args[0].preferred_config_values.get("effort").map(String::as_str), + Some("high") + ); + } + + #[tokio::test] + async fn omitted_per_call_config_keeps_configured_defaults() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("child-1".into())).await; + mock.queue_send(Err(SpawnerError::Send("stop after spawn".into()))) + .await; + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + + let mut claude_cfg = BTreeMap::new(); + claude_cfg.insert("model".into(), "claude-sonnet-4-5".into()); + let mut agent_defaults = BTreeMap::new(); + agent_defaults.insert( + AgentType::ClaudeCode, + AgentDelegationDefaults { + mode_id: None, + config_values: claude_cfg.clone(), + }, + ); + broker + .set_config(DelegationConfig { + enabled: true, + depth_limit: 8, + agent_defaults, + ..DelegationConfig::default() + }) + .await; + + let _ = broker.handle_request(request(1, "pt-1")).await; + let args = mock.spawn_args.lock().await; + assert_eq!(args[0].preferred_config_values, claude_cfg); + } + #[tokio::test] async fn send_failure_after_spawn_disconnects_child() { let mock = Arc::new(MockSpawner::new()); diff --git a/src-tauri/src/acp/delegation/listener.rs b/src-tauri/src/acp/delegation/listener.rs index cd60d31d47..61187b58c8 100644 --- a/src-tauri/src/acp/delegation/listener.rs +++ b/src-tauri/src/acp/delegation/listener.rs @@ -7,7 +7,7 @@ //! [`DelegationBroker`]. The listener is the boundary between the wire and //! the broker, plus the place where the per-launch token policy is enforced. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -814,6 +814,11 @@ impl DelegationListener { .clone() .or_else(|| Some(entry.working_dir.to_string_lossy().to_string())); + // Optional per-call session knobs. Blank/whitespace is omitted so a + // model emitting `""` cannot clear the configured default. + let model = optional_string_arg(req.input.get("model")); + let config_values = parse_config_overlay(req.input.get("config")); + let delegation_req = DelegationRequest { parent_connection_id: req.parent_connection_id, parent_conversation_id, @@ -822,12 +827,40 @@ impl DelegationListener { task, working_dir, requested_working_dir, + model, + config_values, external_handle: req.external_handle, }; self.broker.start_delegation(delegation_req).await } } +fn optional_string_arg(value: Option<&Value>) -> Option { + value + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn parse_config_overlay(value: Option<&Value>) -> BTreeMap { + let Some(obj) = value.and_then(|v| v.as_object()) else { + return BTreeMap::new(); + }; + let mut out = BTreeMap::new(); + for (key, val) in obj { + let key = key.trim(); + if key.is_empty() { + continue; + } + let Some(s) = val.as_str().map(str::trim).filter(|s| !s.is_empty()) else { + continue; + }; + out.insert(key.to_string(), s.to_string()); + } + out +} + /// Serialize a [`DelegationTaskReport`] into a [`BrokerResponse`] for the wire. /// Used by the `Call` / `CancelTask` arms, which each resolve to one report. fn report_response(report: DelegationTaskReport) -> std::io::Result { @@ -1522,6 +1555,8 @@ mod tests { task: "do x".into(), working_dir: None, requested_working_dir: None, + model: None, + config_values: BTreeMap::new(), external_handle: None, }) .await; @@ -1675,6 +1710,8 @@ mod tests { task: "do x".into(), working_dir: None, requested_working_dir: None, + model: None, + config_values: BTreeMap::new(), external_handle: None, }) .await @@ -1777,6 +1814,8 @@ mod tests { task: "do x".into(), working_dir: None, requested_working_dir: None, + model: None, + config_values: BTreeMap::new(), external_handle: None, }) .await; @@ -1828,6 +1867,8 @@ mod tests { task: "do x".into(), working_dir: None, requested_working_dir: None, + model: None, + config_values: BTreeMap::new(), external_handle: Some("h-1".into()), }; broker.handle_request(req).await diff --git a/src-tauri/src/acp/delegation/tool_schema.json b/src-tauri/src/acp/delegation/tool_schema.json index b15bb368f3..5f008d16bd 100644 --- a/src-tauri/src/acp/delegation/tool_schema.json +++ b/src-tauri/src/acp/delegation/tool_schema.json @@ -34,6 +34,15 @@ "working_dir": { "type": "string", "description": "Absolute path the sub-agent runs in. Defaults to this session's working directory." + }, + "model": { + "type": "string", + "description": "Optional. Model id the sub-agent starts on for THIS delegation only. Use the same id the target agent's model selector shows. Overrides the per-agent Settings default. Omit to keep that default. Agents with no model selector ignore it." + }, + "config": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Optional. Other session controls for THIS delegation only, using the same option ids as that agent's Settings defaults (reasoning, context, fast, and whatever else it exposes). Each value is the option's id. Omit a key to keep the configured default. Unknown keys are ignored." } } } diff --git a/src-tauri/src/acp/delegation/types.rs b/src-tauri/src/acp/delegation/types.rs index bb597b9720..b828657261 100644 --- a/src-tauri/src/acp/delegation/types.rs +++ b/src-tauri/src/acp/delegation/types.rs @@ -69,6 +69,18 @@ pub struct DelegationRequest { /// the defaulted value the child is actually spawned in. #[serde(default, skip_serializing_if = "Option::is_none")] pub requested_working_dir: Option, + /// Model id the child should start on, as the LLM passed it in + /// `delegate_to_agent`. Written into `preferred_config_values["model"]` + /// after the per-agent Settings defaults and any per-call `config` + /// map, so this field wins. `None` keeps the configured default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Extra session config overrides for THIS call only (reasoning, + /// context, fast, whatever else that agent exposes). Merged over the + /// per-agent Settings defaults; unknown keys are ignored by the + /// spawner the same way Settings already ignores them. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub config_values: BTreeMap, #[serde(default, skip_serializing_if = "Option::is_none")] pub external_handle: Option, } diff --git a/src-tauri/src/acp/lifecycle.rs b/src-tauri/src/acp/lifecycle.rs index 2779992305..6a5475b2cf 100644 --- a/src-tauri/src/acp/lifecycle.rs +++ b/src-tauri/src/acp/lifecycle.rs @@ -2911,6 +2911,8 @@ mod tests { task: "do x".into(), working_dir: None, requested_working_dir: None, + model: None, + config_values: std::collections::BTreeMap::new(), external_handle: None, } } diff --git a/src/components/chat/sub-agent-overlay.tsx b/src/components/chat/sub-agent-overlay.tsx index 16a746e402..ed7bcb408f 100644 --- a/src/components/chat/sub-agent-overlay.tsx +++ b/src/components/chat/sub-agent-overlay.tsx @@ -128,6 +128,7 @@ const SubAgentOverlayRow = memo(function SubAgentOverlayRow({ errorCode, childConversationId, childConnectionId, + model, } = useDelegationCardModel(source) // Unlike the inline DelegatedSubThread (which falls through to the generic @@ -160,6 +161,14 @@ const SubAgentOverlayRow = memo(function SubAgentOverlayRow({ #{taskId.slice(0, 8)} )} + {model && ( + + {model} + + )} {task && ( diff --git a/src/components/message/delegated-sub-thread.tsx b/src/components/message/delegated-sub-thread.tsx index 43be8e7a77..ea0749a934 100644 --- a/src/components/message/delegated-sub-thread.tsx +++ b/src/components/message/delegated-sub-thread.tsx @@ -78,6 +78,7 @@ export function DelegatedSubThread({ errorCode, childConversationId, childConnectionId, + model, hasModel, } = useDelegationCardModel(source) @@ -96,6 +97,7 @@ export function DelegatedSubThread({ )} + {pinnedModel && ( + + {pinnedModel} + + )} {task && ( diff --git a/src/hooks/use-delegation-card-model.ts b/src/hooks/use-delegation-card-model.ts index 374b58414b..9c1937d310 100644 --- a/src/hooks/use-delegation-card-model.ts +++ b/src/hooks/use-delegation-card-model.ts @@ -61,6 +61,9 @@ export interface DelegationCardModel { errorCode: string | undefined childConversationId: number | null childConnectionId: string | null + /** Model the parent pinned for this delegation, or `null` when it used + * the configured default. */ + model: string | null /** False when there's no live binding and the input parsed to neither an * agent type nor a task — nothing useful to draw. Callers render null. */ hasModel: boolean @@ -214,6 +217,7 @@ export function useDelegationCardModel( errorCode, childConversationId, childConnectionId, + model: parsed.model, // Broker-stamped meta alone is proof enough of a delegation — the // persisted Cursor shape has empty raw_input and no live binding. So is a // report that named the child: a persisted `resume_delegation` result has diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 127e49a60d..d92147297d 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -3346,6 +3346,7 @@ "noDetail": "No detail available yet.", "unknownAgent": "وكيل فرعي", "openDetail": "عرض المحادثة", + "delegationPinnedModel": "بدأ على {model}، حدده الأصل لهذه الإحالة", "resumed": "تم الاستئناف", "resumeDetail": "تفاصيل الاستئناف", "resumeReasonLabel": "سبب الاستئناف", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 6fd288d56b..db99134b68 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -3346,6 +3346,7 @@ "noDetail": "No detail available yet.", "unknownAgent": "Sub-Agent", "openDetail": "Konversation anzeigen", + "delegationPinnedModel": "Gestartet mit {model}, vom Eltern-Agenten für diese Delegation festgelegt", "resumed": "Fortgesetzt", "resumeDetail": "Details zur Fortsetzung", "resumeReasonLabel": "Grund der Fortsetzung", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index c6b14a589a..5fed5ed642 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3346,6 +3346,7 @@ "noDetail": "No detail available yet.", "unknownAgent": "Sub-agent", "openDetail": "Open conversation", + "delegationPinnedModel": "Started on {model}, pinned by the parent for this delegation", "resumed": "Resumed", "resumeDetail": "Resume details", "resumeReasonLabel": "Resume reason", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 8f40f98c68..643cb83b3d 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -3346,6 +3346,7 @@ "noDetail": "No detail available yet.", "unknownAgent": "Sub-agente", "openDetail": "Ver conversación", + "delegationPinnedModel": "Iniciado con {model}, fijado por el padre para esta delegación", "resumed": "Reanudado", "resumeDetail": "Detalles de reanudación", "resumeReasonLabel": "Motivo de reanudación", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 96a879e02a..d8634b127d 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -3346,6 +3346,7 @@ "noDetail": "Aucun détail disponible pour le moment.", "unknownAgent": "Sous-agent", "openDetail": "Voir la conversation", + "delegationPinnedModel": "Démarré sur {model}, fixé par le parent pour cette délégation", "resumed": "Repris", "resumeDetail": "Détails de la reprise", "resumeReasonLabel": "Motif de la reprise", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index e51e9d5bda..5a12f192bb 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -3346,6 +3346,7 @@ "noDetail": "No detail available yet.", "unknownAgent": "サブエージェント", "openDetail": "会話を表示", + "delegationPinnedModel": "この委任では親が {model} を指定しました", "resumed": "再開済み", "resumeDetail": "再開の詳細", "resumeReasonLabel": "再開理由", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 1ef0a96018..7a75579e4e 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -3346,6 +3346,7 @@ "noDetail": "No detail available yet.", "unknownAgent": "하위 에이전트", "openDetail": "대화 보기", + "delegationPinnedModel": "이 위임에서 부모가 {model} 을(를) 지정했습니다", "resumed": "재개됨", "resumeDetail": "재개 세부 정보", "resumeReasonLabel": "재개 사유", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index d85a1a4d27..91f70e4df1 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -3346,6 +3346,7 @@ "noDetail": "No detail available yet.", "unknownAgent": "Subagente", "openDetail": "Ver conversa", + "delegationPinnedModel": "Iniciado em {model}, definido pelo pai para esta delegação", "resumed": "Retomado", "resumeDetail": "Detalhes da retomada", "resumeReasonLabel": "Motivo da retomada", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 61025ce7b7..f5a0b4fe85 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -3346,6 +3346,7 @@ "noDetail": "暂无详情。", "unknownAgent": "子智能体", "openDetail": "查看会话", + "delegationPinnedModel": "本次委派由父级指定使用 {model}", "resumed": "已恢复", "resumeDetail": "恢复详情", "resumeReasonLabel": "恢复原因", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index b22ae33f1d..40a583fdfb 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -3346,6 +3346,7 @@ "noDetail": "暫無詳情。", "unknownAgent": "子智慧體", "openDetail": "檢視會話", + "delegationPinnedModel": "此次委派由上層指定使用 {model}", "resumed": "已恢復", "resumeDetail": "恢復詳情", "resumeReasonLabel": "恢復原因", diff --git a/src/lib/delegation-card.test.ts b/src/lib/delegation-card.test.ts index a89bb5303e..933d457e28 100644 --- a/src/lib/delegation-card.test.ts +++ b/src/lib/delegation-card.test.ts @@ -22,6 +22,29 @@ describe("parseInput wrapper peeling", () => { expect(parsed.agentType).toBe("codex") expect(parsed.task).toBe("run the build") expect(parsed.workingDir).toBe("/tmp/proj") + expect(parsed.model).toBeNull() + }) + + it("reads a per-call model", () => { + const parsed = parseInput( + JSON.stringify({ + agent_type: "codex", + task: "run the build", + model: "gpt-5.4", + }) + ) + expect(parsed.model).toBe("gpt-5.4") + }) + + it("treats a blank model as omitted", () => { + const parsed = parseInput( + JSON.stringify({ + agent_type: "codex", + task: "run the build", + model: " ", + }) + ) + expect(parsed.model).toBeNull() }) it("peels Cursor's MCP args wrapper", () => { diff --git a/src/lib/delegation-card.ts b/src/lib/delegation-card.ts index 178a60ba70..ed5e73aa08 100644 --- a/src/lib/delegation-card.ts +++ b/src/lib/delegation-card.ts @@ -36,6 +36,8 @@ export type ParsedInput = { agentType: AgentType | null task: string | null workingDir: string | null + /** Model the parent pinned for this one delegation. `null` when omitted. */ + model: string | null } // Derived from the canonical `ALL_AGENT_TYPES` so a newly added agent is @@ -141,6 +143,7 @@ const EMPTY_PARSED_INPUT: ParsedInput = { agentType: null, task: null, workingDir: null, + model: null, } // Wrapper keys that hosts use to nest the actual tool arguments. JSON-RPC @@ -182,7 +185,8 @@ function findDelegationArgs( if ( typeof obj.task === "string" || typeof obj.agent_type === "string" || - typeof obj.working_dir === "string" + typeof obj.working_dir === "string" || + typeof obj.model === "string" ) { return obj } @@ -264,6 +268,10 @@ export function parseInput(raw: string | null | undefined): ParsedInput { agentType: coerceAgentType(obj.agent_type), task: typeof obj.task === "string" ? obj.task : null, workingDir: typeof obj.working_dir === "string" ? obj.working_dir : null, + model: + typeof obj.model === "string" && obj.model.trim() + ? obj.model.trim() + : null, } }