Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
102 changes: 102 additions & 0 deletions src-tauri/src/acp/delegation/broker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>,
per_call: &BTreeMap<String, String>,
model: Option<String>,
) -> BTreeMap<String, String> {
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(
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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<dyn ConnectionSpawner>, 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<dyn ConnectionSpawner>, 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());
Expand Down
43 changes: 42 additions & 1 deletion src-tauri/src/acp/delegation/listener.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand All @@ -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<String> {
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<String, String> {
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<BrokerResponse> {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src-tauri/src/acp/delegation/tool_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
Expand Down
12 changes: 12 additions & 0 deletions src-tauri/src/acp/delegation/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String>,
/// 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<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub external_handle: Option<String>,
}
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/acp/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down
9 changes: 9 additions & 0 deletions src/components/chat/sub-agent-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -155,6 +156,14 @@ const SubAgentOverlayRow = memo(function SubAgentOverlayRow({
#{taskId.slice(0, 8)}
</span>
)}
{model && (
<span
className="shrink-0 rounded border border-border px-1 py-px font-mono text-[10px] leading-none text-muted-foreground"
title={t("delegationPinnedModel", { model })}
>
{model}
</span>
)}
<StatusBadge status={status} errorCode={errorCode} />
</div>
{task && (
Expand Down
9 changes: 9 additions & 0 deletions src/components/message/delegated-sub-thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export function DelegatedSubThread({
errorCode,
childConversationId,
childConnectionId,
model,
hasModel,
} = useDelegationCardModel({
parentToolUseId,
Expand Down Expand Up @@ -112,6 +113,14 @@ export function DelegatedSubThread({
#{taskId.slice(0, 8)}
</span>
)}
{model && (
<span
className="shrink-0 rounded border border-border px-1 py-px font-mono text-[10px] leading-none text-muted-foreground"
title={t("delegationPinnedModel", { model })}
>
{model}
</span>
)}
<StatusBadge status={status} errorCode={errorCode} />
</div>
{task && (
Expand Down
4 changes: 4 additions & 0 deletions src/hooks/use-delegation-card-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions src/i18n/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -2985,6 +2985,7 @@
"noDetail": "No detail available yet.",
"unknownAgent": "وكيل فرعي",
"openDetail": "عرض المحادثة",
"delegationPinnedModel": "بدأ على {model}، حدده الأصل لهذه الإحالة",
"detailTitle": "محادثة الوكيل الفرعي",
"detailDescription": "عرض للقراءة فقط لمحادثة الوكيل الفرعي المُفوَّض.",
"waitForResult": "في انتظار نتيجة المهمة {task}",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -2985,6 +2985,7 @@
"noDetail": "No detail available yet.",
"unknownAgent": "サブエージェント",
"openDetail": "会話を表示",
"delegationPinnedModel": "この委任では親が {model} を指定しました",
"detailTitle": "サブエージェントの会話",
"detailDescription": "委任されたサブエージェントの会話を読み取り専用で表示します。",
"waitForResult": "タスク {task} の実行結果を待機中",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/messages/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -2985,6 +2985,7 @@
"noDetail": "No detail available yet.",
"unknownAgent": "하위 에이전트",
"openDetail": "대화 보기",
"delegationPinnedModel": "이 위임에서 부모가 {model} 을(를) 지정했습니다",
"detailTitle": "서브에이전트 대화",
"detailDescription": "위임된 서브에이전트 대화를 읽기 전용으로 봅니다.",
"waitForResult": "작업 {task} 실행 결과 대기 중",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/messages/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
Loading
Loading