diff --git a/src-tauri/src/acp/delegation/broker.rs b/src-tauri/src/acp/delegation/broker.rs index 6c428f34b..56c8bf8d6 100644 --- a/src-tauri/src/acp/delegation/broker.rs +++ b/src-tauri/src/acp/delegation/broker.rs @@ -754,6 +754,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( @@ -2301,6 +2317,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. @@ -3757,6 +3778,8 @@ mod tests { task: "do x".into(), working_dir: None, requested_working_dir: None, + model: None, + config_values: BTreeMap::new(), external_handle: None, } } @@ -4517,6 +4540,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 f407c33ba..19b06116b 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; @@ -631,6 +631,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, @@ -639,12 +644,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 { @@ -1337,6 +1370,8 @@ mod tests { task: "do x".into(), working_dir: None, requested_working_dir: None, + model: None, + config_values: BTreeMap::new(), external_handle: None, }) .await; @@ -1490,6 +1525,8 @@ mod tests { task: "do x".into(), working_dir: None, requested_working_dir: None, + model: None, + config_values: BTreeMap::new(), external_handle: None, }) .await @@ -1592,6 +1629,8 @@ mod tests { task: "do x".into(), working_dir: None, requested_working_dir: None, + model: None, + config_values: BTreeMap::new(), external_handle: None, }) .await; @@ -1643,6 +1682,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 08002aaf4..e7e7cad1f 100644 --- a/src-tauri/src/acp/delegation/tool_schema.json +++ b/src-tauri/src/acp/delegation/tool_schema.json @@ -32,6 +32,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 b39664bcc..f75671252 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 2579cecdc..70c8a713d 100644 --- a/src-tauri/src/acp/lifecycle.rs +++ b/src-tauri/src/acp/lifecycle.rs @@ -2736,6 +2736,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 31ab3c81b..60847d0b4 100644 --- a/src/components/chat/sub-agent-overlay.tsx +++ b/src/components/chat/sub-agent-overlay.tsx @@ -123,6 +123,7 @@ const SubAgentOverlayRow = memo(function SubAgentOverlayRow({ errorCode, childConversationId, childConnectionId, + model, } = useDelegationCardModel(source) // Unlike the inline DelegatedSubThread (which falls through to the generic @@ -155,6 +156,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 993e090cc..fbf4f1756 100644 --- a/src/components/message/delegated-sub-thread.tsx +++ b/src/components/message/delegated-sub-thread.tsx @@ -68,6 +68,7 @@ export function DelegatedSubThread({ errorCode, childConversationId, childConnectionId, + model, hasModel, } = useDelegationCardModel({ parentToolUseId, @@ -112,6 +113,14 @@ export function DelegatedSubThread({ #{taskId.slice(0, 8)} )} + {model && ( + + {model} + + )} {task && ( diff --git a/src/hooks/use-delegation-card-model.ts b/src/hooks/use-delegation-card-model.ts index 0d0b3eda6..9dd25239f 100644 --- a/src/hooks/use-delegation-card-model.ts +++ b/src/hooks/use-delegation-card-model.ts @@ -53,6 +53,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 @@ -150,6 +153,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. hasModel: Boolean(binding || parsed.agentType || parsed.task || parsedMeta), diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index bdf81590d..0254730fc 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2985,6 +2985,7 @@ "noDetail": "No detail available yet.", "unknownAgent": "وكيل فرعي", "openDetail": "عرض المحادثة", + "delegationPinnedModel": "بدأ على {model}، حدده الأصل لهذه الإحالة", "detailTitle": "محادثة الوكيل الفرعي", "detailDescription": "عرض للقراءة فقط لمحادثة الوكيل الفرعي المُفوَّض.", "waitForResult": "في انتظار نتيجة المهمة {task}", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 0c40db5d3..3135f07c8 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2985,6 +2985,7 @@ "noDetail": "No detail available yet.", "unknownAgent": "Sub-Agent", "openDetail": "Konversation anzeigen", + "delegationPinnedModel": "Gestartet mit {model}, vom Eltern-Agenten für diese Delegation festgelegt", "detailTitle": "Unteragent-Konversation", "detailDescription": "Schreibgeschützte Ansicht der delegierten Unteragent-Konversation.", "waitForResult": "Warte auf das Ergebnis von Aufgabe {task}", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 273af3aa0..8b8e8ad88 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2985,6 +2985,7 @@ "noDetail": "No detail available yet.", "unknownAgent": "Sub-agent", "openDetail": "Open conversation", + "delegationPinnedModel": "Started on {model}, pinned by the parent for this delegation", "detailTitle": "Sub-agent conversation", "detailDescription": "Read-only view of the delegated sub-agent's conversation.", "waitForResult": "Waiting for task {task} result", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 9948070a4..82556a0b3 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2985,6 +2985,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", "detailTitle": "Conversación del subagente", "detailDescription": "Vista de solo lectura de la conversación del subagente delegado.", "waitForResult": "Esperando el resultado de la tarea {task}", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 9638508d6..fa5938a01 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2985,6 +2985,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", "detailTitle": "Conversation du sous-agent", "detailDescription": "Vue en lecture seule de la conversation du sous-agent délégué.", "waitForResult": "En attente du résultat de la tâche {task}", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index f44e8c88d..78a4e4715 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2985,6 +2985,7 @@ "noDetail": "No detail available yet.", "unknownAgent": "サブエージェント", "openDetail": "会話を表示", + "delegationPinnedModel": "この委任では親が {model} を指定しました", "detailTitle": "サブエージェントの会話", "detailDescription": "委任されたサブエージェントの会話を読み取り専用で表示します。", "waitForResult": "タスク {task} の実行結果を待機中", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index d8c404715..26467e154 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2985,6 +2985,7 @@ "noDetail": "No detail available yet.", "unknownAgent": "하위 에이전트", "openDetail": "대화 보기", + "delegationPinnedModel": "이 위임에서 부모가 {model} 을(를) 지정했습니다", "detailTitle": "서브에이전트 대화", "detailDescription": "위임된 서브에이전트 대화를 읽기 전용으로 봅니다.", "waitForResult": "작업 {task} 실행 결과 대기 중", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 5b31f9ead..1dc98e09c 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2985,6 +2985,7 @@ "noDetail": "No detail available yet.", "unknownAgent": "Subagente", "openDetail": "Ver conversa", + "delegationPinnedModel": "Iniciado em {model}, definido pelo pai para esta delegação", "detailTitle": "Conversa do subagente", "detailDescription": "Visualização somente leitura da conversa do subagente delegado.", "waitForResult": "Aguardando o resultado da tarefa {task}", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index f6a2638c1..f44377010 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2985,6 +2985,7 @@ "noDetail": "暂无详情。", "unknownAgent": "子智能体", "openDetail": "查看会话", + "delegationPinnedModel": "本次委派由父级指定使用 {model}", "detailTitle": "子智能体会话", "detailDescription": "只读查看委托给子智能体的会话内容。", "waitForResult": "等待 {task} 任务执行结果", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 13e7b6eae..4331f4f6f 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2985,6 +2985,7 @@ "noDetail": "暫無詳情。", "unknownAgent": "子智慧體", "openDetail": "檢視會話", + "delegationPinnedModel": "此次委派由上層指定使用 {model}", "detailTitle": "子智慧體會話", "detailDescription": "唯讀檢視委派給子智慧體的會話內容。", "waitForResult": "等待 {task} 任務執行結果", diff --git a/src/lib/delegation-card.test.ts b/src/lib/delegation-card.test.ts index 9492a7dfc..a440aba21 100644 --- a/src/lib/delegation-card.test.ts +++ b/src/lib/delegation-card.test.ts @@ -20,6 +20,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 9231dd06d..04f401d1e 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 @@ -116,6 +118,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 @@ -157,7 +160,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 } @@ -240,6 +244,10 @@ export function parseInput(raw: string | null | undefined): ParsedInput { agentType: at && KNOWN_AGENT_TYPES.has(at) ? (at as AgentType) : null, 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, } }