返回 CodeWhale
worker_runtime.rs
根目录 / crates / tui / src / fleet / worker_runtime.rs
1 //! Fleet worker runtime — bridges fleet task specs to headless sub-agent execution.
2 //!
3 //! This module makes fleet workers real: instead of simulating task completion,
4 //! each fleet worker spawns a headless sub-agent that runs the task instructions
5 //! and streams progress back into the fleet ledger.
6 //!
7 //! Architecture:
8 //! - `FleetTaskSpec` + `FleetWorkerSpec` → `AgentWorkerSpec`
9 //! - `SubAgentManager::register_worker()` tracks the worker
10 //! - Sub-agent spawn happens through the existing `agent` machinery
11 //! - Mailbox events stream into fleet ledger as `FleetWorkerEventPayload`
12 //! - `FleetWorkerInspection` reads both ledger state and sub-agent worker records
13
14 #![allow(dead_code)]
15
16 use anyhow::{Result, bail};
17 use codewhale_protocol::fleet::{
18 FleetEffectivePermissions, FleetResolvedRoute, FleetTaskSpec, FleetTaskWorkerProfile,
19 FleetWorkerSpec,
20 };
21
22 use super::profile::{AgentProfile, canonical_public_role_name};
23 use crate::config::{ApiProvider, Config};
24 use crate::route_runtime::{resolve_route_candidate, resolve_runtime_route};
25 use crate::tools::subagent::{AgentWorkerSpec, AgentWorkerToolProfile, FleetRole};
26 use crate::worker_profile::{ChildLaunchManifest, ModelRoute, ToolScope, WorkerRuntimeProfile};
27
28 /// Validate that every task referencing a workspace agent profile can resolve it.
29 ///
30 /// This is intended to run at Fleet run creation time, before leasing any
31 /// worker or appending lifecycle events.
32 pub fn validate_task_agent_profiles(
33 tasks: &[FleetTaskSpec],
34 agent_profiles: &[AgentProfile],
35 ) -> Result<()> {
36 for task in tasks {
37 resolve_task_agent_profile(task, agent_profiles)?;
38 }
39 Ok(())
40 }
41
42 /// Rewrite compatibility-only advisory role names before a Fleet run is
43 /// persisted. Replayed older ledgers are still canonicalized at projection
44 /// boundaries, but every newly created durable task records `consultant`.
45 pub(crate) fn canonicalize_fleet_task_roles(tasks: &mut [FleetTaskSpec]) {
46 for task in tasks {
47 let Some(role) = task.worker.as_mut().and_then(|worker| worker.role.as_mut()) else {
48 continue;
49 };
50 *role = canonical_public_role_name(role.trim());
51 }
52 }
53
54 /// Validate that every task's pinned model route actually resolves before any
55 /// worker is leased (#4866).
56 ///
57 /// Catches the "provider-less model pin" failure mode: a profile that pins a
58 /// concrete model without an explicit provider resolves against the
59 /// session/default provider, which may not carry that model — causing a silent
60 /// launch failure (e.g. selecting `gpt-5.6-luna` as a Fleet Builder model with
61 /// no provider, when Luna lives on a different configured provider). The
62 /// runtime never infers a provider from a model's spelling (#4093/#2608), so a
63 /// pinned model that does not resolve is rejected here with a clear error
64 /// instead of failing silently inside the worker. Models inherited from the
65 /// session/run route (`run.model`) are not pins and are left alone.
66 pub fn validate_fleet_task_routes(
67 tasks: &[FleetTaskSpec],
68 agent_profiles: &[AgentProfile],
69 session_model: Option<&str>,
70 config: Option<&Config>,
71 ) -> Result<()> {
72 let run_model = session_model.unwrap_or("auto");
73 for task in tasks {
74 let agent_profile = resolve_task_agent_profile(task, agent_profiles)
75 .ok()
76 .flatten();
77 let (model, source) =
78 effective_fleet_model_with_source(run_model, task.worker.as_ref(), agent_profile);
79 let pinned_model = matches!(source, "task.model" | "agent_profile.model");
80 let explicit_provider = explicit_fleet_provider_id(agent_profile);
81 if pinned_model && explicit_provider.is_none() {
82 let (provider, base_url) = config.map_or_else(
83 || {
84 let provider = ApiProvider::Deepseek;
85 (provider, provider.default_base_url().to_string())
86 },
87 |config| (config.api_provider(), config.deepseek_base_url()),
88 );
89 if let Err(reason) =
90 crate::route_runtime::validate_unpinned_model_provider(provider, &model, &base_url)
91 {
92 bail!("Fleet task `{}`: {reason} (source={source})", task.id);
93 }
94 }
95
96 let route = resolve_fleet_route_with_config(task, agent_profiles, session_model, config);
97 if route.is_some() {
98 validate_fleet_reasoning_effort(task, agent_profiles, session_model, config)?;
99 }
100 if !pinned_model {
101 continue;
102 }
103 // A concrete model is pinned at the task/profile level; it must resolve
104 // to a real provider route or the worker cannot launch.
105 if route.is_some() {
106 continue;
107 }
108 let provider = explicit_provider
109 .map(|provider| format!("provider=`{provider}`"))
110 .unwrap_or_else(|| {
111 "no explicit provider (resolves against the session/default provider)".to_string()
112 });
113 bail!(
114 "Fleet task `{}` pins model `{}` with {} (source={source}), but that route does not \
115 resolve to a real model on any configured provider, so the worker cannot launch. \
116 The runtime never infers a provider from a model's spelling — set an explicit \
117 provider for this model in the profile, or switch the role to `inherit`.",
118 task.id,
119 model,
120 provider
121 );
122 }
123 Ok(())
124 }
125
126 /// Reject an explicit Fleet thinking tier when the exact resolved route does
127 /// not advertise reasoning support. `inherit`, `auto`, and `off` are valid on
128 /// every route because they do not force a reasoning payload. This check is
129 /// deliberately performed at run creation, after the same route resolver used
130 /// for launch, so the UI cannot save a profile that will silently downgrade or
131 /// fail at spawn time (#4866).
132 fn validate_fleet_reasoning_effort(
133 task: &FleetTaskSpec,
134 agent_profiles: &[AgentProfile],
135 session_model: Option<&str>,
136 config: Option<&Config>,
137 ) -> Result<()> {
138 let agent_profile = resolve_task_agent_profile(task, agent_profiles)
139 .ok()
140 .flatten();
141 let Some(effort) =
142 effective_fleet_reasoning_effort_for_role(task.worker.as_ref(), agent_profile)
143 else {
144 return Ok(());
145 };
146 if matches!(effort.as_str(), "inherit" | "auto" | "off") {
147 return Ok(());
148 }
149 let Some(route) = resolve_fleet_route_with_config(task, agent_profiles, session_model, config)
150 else {
151 // The model-route validator owns unresolved-route errors and produces
152 // the more useful provider/model diagnosis.
153 return Ok(());
154 };
155 let provider = ApiProvider::parse(&route.provider_kind).unwrap_or(ApiProvider::Custom);
156 let capability = crate::config::provider_capability(provider, &route.wire_model_id);
157 if capability.thinking_supported {
158 return Ok(());
159 }
160 bail!(
161 "Fleet task `{}` requests thinking tier `{effort}` for `{}` / `{}`, but that exact model route does not support thinking; choose inherit, auto, or off, or select a reasoning-capable model",
162 task.id,
163 route.provider_id,
164 route.wire_model_id,
165 );
166 }
167
168 /// Build a sub-agent worker spec after resolving workspace Fleet profile input.
169 ///
170 /// This keeps Fleet and sub-agents on the same runtime substrate: profile files
171 /// and task-level role/loadout intent are composed into the existing
172 /// `AgentWorkerSpec` / `WorkerRuntimeProfile` pair, then optionally intersected
173 /// with a parent profile when the caller has one.
174 /// A worker workspace is isolated when it is a linked git worktree that sits
175 /// outside the coordinating manager's workspace (and does not contain it):
176 /// its mutations cannot overlap the shared checkout, so its launch manifest
177 /// must not claim the shared-workspace coordination scope (#5036).
178 fn worker_workspace_is_isolated(
179 coordination_workspace: &std::path::Path,
180 worker_workspace: &std::path::Path,
181 ) -> bool {
182 let canonical =
183 |path: &std::path::Path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
184 let manager = canonical(coordination_workspace);
185 let worker = canonical(worker_workspace);
186 if worker == manager || worker.starts_with(&manager) || manager.starts_with(&worker) {
187 return false;
188 }
189 worker.join(".git").is_file()
190 }
191
192 #[allow(clippy::too_many_arguments)]
193 pub fn fleet_task_to_worker_spec_with_profiles(
194 worker_id: &str,
195 run_id: &str,
196 task_spec: &FleetTaskSpec,
197 _worker_spec: &FleetWorkerSpec,
198 model: &str,
199 workspace: &std::path::Path,
200 coordination_workspace: &std::path::Path,
201 agent_profiles: &[AgentProfile],
202 parent_runtime_profile: Option<&WorkerRuntimeProfile>,
203 ) -> Result<AgentWorkerSpec> {
204 let agent_profile = resolve_task_agent_profile(task_spec, agent_profiles)?;
205 let worker_profile = task_spec.worker.as_ref();
206 let role = effective_fleet_role(worker_profile, agent_profile);
207 let agent_type = fleet_role_to_agent_type(role.as_deref());
208 let tool_profile = fleet_tool_profile(worker_profile);
209 let objective = fleet_task_prompt_with_profile(task_spec, agent_profile);
210 let max_spawn_depth = codewhale_config::FleetExecConfig::default().max_spawn_depth;
211 let loadout = effective_fleet_loadout(worker_profile, agent_profile);
212 let (effective_model, model_source) =
213 effective_fleet_model_with_source(model, worker_profile, agent_profile);
214 let mut requested_runtime = fleet_worker_runtime_profile_for_loadout(
215 &agent_type,
216 &tool_profile,
217 &effective_model,
218 0,
219 max_spawn_depth,
220 &loadout,
221 model_source,
222 );
223 requested_runtime.provider = explicit_fleet_provider_id(agent_profile);
224 if let Some(reasoning_effort) = effective_fleet_reasoning_effort(agent_profile) {
225 requested_runtime.reasoning_effort = Some(reasoning_effort);
226 }
227 if let Some(agent_profile) = agent_profile
228 && let Some(profile_depth) = agent_profile.profile.delegation.max_spawn_depth
229 {
230 requested_runtime.max_spawn_depth = requested_runtime.max_spawn_depth.min(profile_depth);
231 }
232 let runtime_profile = parent_runtime_profile
233 .map(|parent| parent.derive_child(&requested_runtime))
234 .unwrap_or(requested_runtime);
235 let writable_roots = fleet_write_roots(task_spec)?;
236 let coordination_contracts = fleet_coordination_contracts(task_spec)?;
237 if runtime_profile.permissions.write
238 && writable_roots.is_empty()
239 && coordination_contracts.is_empty()
240 {
241 bail!(
242 "fleet task '{}' is write-capable but declares no workspace.writable_paths or metadata.coordination_contracts",
243 task_spec.id
244 );
245 }
246 let session_name = format!("fleet-{}-{}", worker_id, task_spec.id);
247 let launch_manifest = ChildLaunchManifest {
248 owner_session: run_id.to_string(),
249 child_id: worker_id.to_string(),
250 profile: runtime_profile.clone(),
251 prompt: objective.clone(),
252 cwd: Some(workspace.display().to_string()),
253 worktree: worker_workspace_is_isolated(coordination_workspace, workspace),
254 writable_roots,
255 writable_files: Vec::new(),
256 coordination_contracts,
257 expected_artifact: None,
258 token_budget: task_spec
259 .budget
260 .as_ref()
261 .and_then(|budget| budget.max_tokens),
262 resume_identity: Some(session_name.clone()),
263 generation: 1,
264 resume_from_agent_id: None,
265 };
266
267 let max_steps = task_spec
268 .budget
269 .as_ref()
270 .and_then(|b| b.max_tool_calls)
271 .unwrap_or_else(|| WorkerRuntimeProfile::default_max_steps(agent_type.clone()));
272
273 Ok(AgentWorkerSpec {
274 worker_id: worker_id.to_string(),
275 run_id: run_id.to_string(),
276 parent_run_id: None,
277 session_name: Some(session_name),
278 objective,
279 role,
280 agent_type,
281 model: effective_model,
282 workspace: workspace.to_path_buf(),
283 git_branch: None,
284 context_mode: "fresh".to_string(),
285 fork_context: false,
286 tool_profile,
287 runtime_profile: runtime_profile.clone(),
288 max_steps,
289 spawn_depth: 0,
290 max_spawn_depth: runtime_profile.max_spawn_depth,
291 launch_manifest: Some(launch_manifest),
292 })
293 }
294
295 pub(crate) fn fleet_write_roots(task_spec: &FleetTaskSpec) -> Result<Vec<String>> {
296 let task_root = normalize_fleet_relative_path(
297 task_spec
298 .workspace
299 .as_ref()
300 .and_then(|workspace| workspace.root.as_deref())
301 .unwrap_or_else(|| std::path::Path::new(".")),
302 &task_spec.id,
303 "workspace.root",
304 )?;
305 let mut roots = Vec::new();
306 for runtime_root in fleet_runtime_write_roots(task_spec)? {
307 let claim_root = match (task_root.as_str(), runtime_root.as_str()) {
308 (".", path) | (path, ".") => path.to_string(),
309 (root, path) => format!("{root}/{path}"),
310 };
311 if !roots.contains(&claim_root) {
312 roots.push(claim_root);
313 }
314 }
315 Ok(roots)
316 }
317
318 pub(crate) fn fleet_runtime_write_roots(task_spec: &FleetTaskSpec) -> Result<Vec<String>> {
319 let mut roots = Vec::new();
320 for path in task_spec
321 .workspace
322 .as_ref()
323 .into_iter()
324 .flat_map(|workspace| &workspace.writable_paths)
325 {
326 let normalized =
327 normalize_fleet_relative_path(path, &task_spec.id, "workspace.writable_paths")?;
328 if !roots.contains(&normalized) {
329 roots.push(normalized);
330 }
331 }
332 Ok(roots)
333 }
334
335 fn normalize_fleet_relative_path(
336 path: &std::path::Path,
337 task_id: &str,
338 field: &str,
339 ) -> Result<String> {
340 let raw = path.to_string_lossy().replace('\\', "/");
341 if raw.chars().any(|ch| matches!(ch, '\0' | '\r' | '\n'))
342 || path.is_absolute()
343 || path.components().any(|component| {
344 matches!(
345 component,
346 std::path::Component::ParentDir
347 | std::path::Component::RootDir
348 | std::path::Component::Prefix(_)
349 )
350 })
351 {
352 bail!(
353 "fleet task '{task_id}' {field} path '{}' must be one repo-relative line and cannot escape the workspace",
354 path.display()
355 );
356 }
357 let mut segments = Vec::new();
358 for segment in raw.split('/') {
359 match segment {
360 "" | "." => {}
361 ".." => {
362 bail!(
363 "fleet task '{task_id}' {field} path '{}' cannot contain parent traversal",
364 path.display()
365 );
366 }
367 value => segments.push(value),
368 }
369 }
370 Ok(if segments.is_empty() {
371 ".".to_string()
372 } else {
373 segments.join("/")
374 })
375 }
376
377 fn fleet_coordination_contracts(task_spec: &FleetTaskSpec) -> Result<Vec<String>> {
378 let Some(value) = task_spec.metadata.get("coordination_contracts") else {
379 return Ok(Vec::new());
380 };
381 let Some(values) = value.as_array() else {
382 bail!(
383 "fleet task '{}' metadata.coordination_contracts must be an array of strings",
384 task_spec.id
385 );
386 };
387 if values.len() > 16 {
388 bail!(
389 "fleet task '{}' metadata.coordination_contracts accepts at most 16 entries",
390 task_spec.id
391 );
392 }
393 let mut contracts = Vec::new();
394 for value in values {
395 let Some(value) = value.as_str() else {
396 bail!(
397 "fleet task '{}' metadata.coordination_contracts must contain only strings",
398 task_spec.id
399 );
400 };
401 let value = value.trim();
402 if value.is_empty()
403 || value.chars().count() > 128
404 || value.chars().any(|ch| matches!(ch, '\0' | '\r' | '\n'))
405 {
406 bail!(
407 "fleet task '{}' coordination contracts must be one non-empty line of at most 128 characters",
408 task_spec.id
409 );
410 }
411 if !contracts.iter().any(|contract| contract == value) {
412 contracts.push(value.to_string());
413 }
414 }
415 Ok(contracts)
416 }
417
418 /// Mint a [`FleetResolvedRoute`] snapshot for a fleet task (#3154).
419 ///
420 /// This calls the existing hermetic resolver bridge
421 /// ([`resolve_route_candidate`]) so the persisted route reflects the same
422 /// resolution semantics the runtime would use, then records only non-sensitive
423 /// shape (provider id/kind, model ids, protocol) combined with the already
424 /// computed effective role/loadout/model-class intent. `source` is
425 /// `"resolver"`.
426 ///
427 /// Honesty rules:
428 /// - `canonical_model` stays `None` when the resolver could not pin one.
429 /// - The provider comes from the resolved agent profile's own explicit
430 /// `provider` field when it has one (#4093) — a Fleet worker profile can be
431 /// pinned to a route independent of the parent/current session provider.
432 /// Absent an explicit pin, the worker profile carries no provider authority
433 /// and resolution falls back to the existing default scope. Either way, the
434 /// provider is NEVER inferred by sniffing a substring/prefix out of `model`
435 /// (EPIC #2608: explicit config only). A task-level `model` selector is
436 /// forwarded as the model selector. No reasoning/pricing fields are
437 /// fabricated.
438 ///
439 /// Returns `None` (never a fabricated route) when resolution fails, so callers
440 /// degrade gracefully without inventing detail.
441 pub(crate) fn resolve_fleet_route(
442 task_spec: &FleetTaskSpec,
443 agent_profiles: &[AgentProfile],
444 session_model: Option<&str>,
445 ) -> Option<FleetResolvedRoute> {
446 resolve_fleet_route_with_config(task_spec, agent_profiles, session_model, None)
447 }
448
449 /// Resolve a Fleet receipt from the same live Config used to launch workers.
450 /// Named custom identities are emitted only through this proof-bearing path;
451 /// the hermetic fallback above cannot truthfully validate arbitrary ids.
452 pub(crate) fn resolve_fleet_route_with_config(
453 task_spec: &FleetTaskSpec,
454 agent_profiles: &[AgentProfile],
455 session_model: Option<&str>,
456 config: Option<&Config>,
457 ) -> Option<FleetResolvedRoute> {
458 let agent_profile = resolve_task_agent_profile(task_spec, agent_profiles)
459 .ok()
460 .flatten();
461 let worker_profile = task_spec.worker.as_ref();
462 let (role, role_source) = effective_fleet_role_with_source(worker_profile, agent_profile);
463 let (loadout, loadout_source) =
464 effective_fleet_loadout_with_source(worker_profile, agent_profile);
465 let (model_class, model_class_source) = task_model_class_with_source(worker_profile);
466
467 // Task/profile model pins are visible route intent; next the session
468 // route (the operator's model) applies as the run-level fallback; only
469 // then does the resolver pick the provider default.
470 let (model_selector, model_source) =
471 fleet_route_model_selector_with_source(worker_profile, agent_profile, session_model);
472 let model_selector = model_selector.as_deref();
473
474 let explicit_provider_id = explicit_fleet_provider_id(agent_profile);
475 let (candidate, provider_id, provider_exact_id, route_source) = if let Some(config) = config {
476 let identity = match explicit_provider_id.as_deref() {
477 Some(provider_id) => config.resolve_provider_identity(provider_id).ok()?,
478 None => config
479 .resolve_provider_identity(&config.provider_identity_for(config.api_provider()))
480 .ok()?,
481 };
482 let mut scoped = config.clone();
483 scoped.provider = Some(identity.key.clone());
484 let route = resolve_runtime_route(&scoped, identity.provider, model_selector)
485 .ok()?
486 .validate()
487 .ok()?;
488 let provider_exact_id = (route.identity.provider == ApiProvider::Custom)
489 .then_some(route.identity.exact_id)
490 .flatten();
491 (
492 route.candidate,
493 route.identity.key,
494 provider_exact_id,
495 "runtime_route",
496 )
497 } else {
498 let provider = match explicit_provider_id.as_deref() {
499 Some(provider_id) => {
500 let provider = ApiProvider::parse(provider_id)?;
501 if provider == ApiProvider::Custom {
502 return None;
503 }
504 provider
505 }
506 None => ApiProvider::Deepseek,
507 };
508 let candidate = resolve_route_candidate(provider, model_selector, None, None, None).ok()?;
509 let provider_id = candidate.provider_id().as_str().to_string();
510 (candidate, provider_id, None, "resolver")
511 };
512
513 Some(FleetResolvedRoute {
514 provider_id,
515 provider_exact_id,
516 provider_kind: candidate.provider_kind().as_str().to_string(),
517 canonical_model: candidate
518 .canonical_model()
519 .as_ref()
520 .map(|model| model.as_str().to_string()),
521 wire_model_id: candidate.wire_model_id().as_str().to_string(),
522 protocol: route_protocol_label(candidate.protocol()).to_string(),
523 role,
524 loadout: loadout_intent_label(&loadout),
525 model_class,
526 model_route: Some(
527 model_route_label(&fleet_model_route_for_loadout(
528 model_selector.unwrap_or("auto"),
529 &loadout,
530 ))
531 .to_string(),
532 ),
533 reasoning_effort: effective_fleet_reasoning_effort_for_role(worker_profile, agent_profile),
534 role_source: role_source.map(str::to_string),
535 loadout_source: loadout_source.map(str::to_string),
536 model_class_source: model_class_source.map(str::to_string),
537 model_source: Some(model_source.to_string()),
538 source: route_source.to_string(),
539 })
540 }
541
542 /// Build the receipt route from route identity reported by the worker itself.
543 ///
544 /// Provider/model fields in this path are process-boundary evidence, not a
545 /// second resolution attempt in the manager's potentially different config.
546 /// Fleet task/profile fields remain intent metadata and are safe to derive
547 /// locally. Protocol and canonical model stay explicitly unreported because
548 /// the current exec terminal envelope does not carry them.
549 pub(crate) fn resolve_fleet_route_from_worker_report(
550 task_spec: &FleetTaskSpec,
551 agent_profiles: &[AgentProfile],
552 session_model: Option<&str>,
553 provider: &str,
554 provider_exact_id: Option<&str>,
555 model: &str,
556 ) -> Option<FleetResolvedRoute> {
557 let provider = non_empty_trimmed(provider)?;
558 let model = non_empty_trimmed(model)?;
559 let provider_exact_id = match provider_exact_id {
560 Some(provider_exact_id) => Some(non_empty_trimmed(provider_exact_id)?),
561 None => None,
562 };
563 let provider_kind = ApiProvider::parse(provider)?;
564 if provider_exact_id.is_some() && provider_kind != ApiProvider::Custom {
565 return None;
566 }
567 let provider_id = provider_exact_id.unwrap_or(provider);
568 let agent_profile = resolve_task_agent_profile(task_spec, agent_profiles)
569 .ok()
570 .flatten();
571 let worker_profile = task_spec.worker.as_ref();
572 let (role, role_source) = effective_fleet_role_with_source(worker_profile, agent_profile);
573 let (loadout, loadout_source) =
574 effective_fleet_loadout_with_source(worker_profile, agent_profile);
575 let (model_class, model_class_source) = task_model_class_with_source(worker_profile);
576 let (model_selector, model_source) =
577 fleet_route_model_selector_with_source(worker_profile, agent_profile, session_model);
578 Some(FleetResolvedRoute {
579 provider_id: provider_id.to_string(),
580 provider_exact_id: provider_exact_id.map(str::to_string),
581 provider_kind: provider_kind.as_str().to_string(),
582 canonical_model: None,
583 wire_model_id: model.to_string(),
584 protocol: "unreported".to_string(),
585 role,
586 loadout: loadout_intent_label(&loadout),
587 model_class,
588 model_route: Some(
589 model_route_label(&fleet_model_route_for_loadout(
590 model_selector.as_deref().unwrap_or("auto"),
591 &loadout,
592 ))
593 .to_string(),
594 ),
595 reasoning_effort: effective_fleet_reasoning_effort_for_role(worker_profile, agent_profile),
596 role_source: role_source.map(str::to_string),
597 loadout_source: loadout_source.map(str::to_string),
598 model_class_source: model_class_source.map(str::to_string),
599 model_source: Some(model_source.to_string()),
600 source: "worker_terminal_metadata".to_string(),
601 })
602 }
603
604 /// Plain-string label for a resolved wire protocol (no config type leaks).
605 fn route_protocol_label(protocol: codewhale_config::route::RequestProtocol) -> &'static str {
606 use codewhale_config::route::RequestProtocol;
607 match protocol {
608 RequestProtocol::ChatCompletions => "chat_completions",
609 RequestProtocol::Responses => "responses",
610 RequestProtocol::AnthropicMessages => "anthropic_messages",
611 }
612 }
613
614 /// Collapse an `inherit` (no-op) loadout to `None` for the receipt.
615 fn loadout_intent_label(loadout: &codewhale_config::FleetLoadout) -> Option<String> {
616 if *loadout == codewhale_config::FleetLoadout::Inherit {
617 None
618 } else {
619 Some(loadout.as_str().to_string())
620 }
621 }
622
623 fn model_route_label(route: &ModelRoute) -> &'static str {
624 match route {
625 ModelRoute::Inherit => "inherit",
626 ModelRoute::Faster => "faster",
627 ModelRoute::Auto => "auto",
628 ModelRoute::Fixed(_) => "fixed",
629 }
630 }
631
632 pub(crate) fn fleet_task_prompt(task_spec: &FleetTaskSpec) -> String {
633 fleet_task_prompt_with_profile(task_spec, None)
634 }
635
636 pub(crate) fn fleet_task_prompt_with_profiles(
637 task_spec: &FleetTaskSpec,
638 agent_profiles: &[AgentProfile],
639 ) -> Result<String> {
640 let agent_profile = resolve_task_agent_profile(task_spec, agent_profiles)?;
641 Ok(fleet_task_prompt_with_profile(task_spec, agent_profile))
642 }
643
644 fn fleet_task_prompt_with_profile(
645 task_spec: &FleetTaskSpec,
646 agent_profile: Option<&AgentProfile>,
647 ) -> String {
648 let role = effective_fleet_role(task_spec.worker.as_ref(), agent_profile)
649 .unwrap_or_else(|| "general".to_string());
650 let mut prompt = String::new();
651 prompt.push_str("You have been summoned as a Codewhale Fleet member (");
652 prompt.push_str(&role);
653 prompt.push_str(") by the Fleet orchestrator.\n\n");
654 prompt.push_str("Fleet operating contract:\n");
655 prompt.push_str("- Work only the assigned slice; keep sibling or topology assumptions out of your answer.\n");
656 prompt.push_str("- Use the policy-gated tools available in this headless worker run.\n");
657 prompt.push_str("- Treat the active provider/model route as inherited unless this task or profile pins a model.\n");
658 prompt.push_str(
659 "- Return concise evidence, gaps, and next actions; the orchestrator will integrate and verify.\n\n",
660 );
661 prompt.push_str("Fleet task: ");
662 prompt.push_str(&task_spec.name);
663
664 if let Some(objective) = task_spec.objective.as_deref() {
665 prompt.push_str("\n\nObjective:\n");
666 prompt.push_str(objective);
667 } else if let Some(description) = task_spec.description.as_deref() {
668 prompt.push_str("\n\nObjective:\n");
669 prompt.push_str(description);
670 }
671
672 prompt.push_str("\n\nInstructions:\n");
673 prompt.push_str(&task_spec.instructions);
674
675 if !task_spec.context.is_empty() {
676 prompt.push_str("\n\nContext:\n");
677 for item in &task_spec.context {
678 prompt.push_str("- ");
679 prompt.push_str(item);
680 prompt.push('\n');
681 }
682 }
683
684 if !task_spec.input_files.is_empty() {
685 prompt.push_str("\nInput files:\n");
686 for path in &task_spec.input_files {
687 prompt.push_str("- ");
688 prompt.push_str(&path.display().to_string());
689 prompt.push('\n');
690 }
691 }
692
693 if let Some(agent_profile) = agent_profile {
694 prompt.push_str("\nFleet profile: ");
695 prompt.push_str(&agent_profile.id);
696 if let Some(display_name) = agent_profile.display_name.as_deref() {
697 prompt.push_str(" (");
698 prompt.push_str(display_name);
699 prompt.push(')');
700 }
701 if let Some(description) = agent_profile.description.as_deref() {
702 prompt.push_str("\nProfile description:\n");
703 prompt.push_str(description);
704 }
705 if let Some(instructions) = agent_profile.profile.role.instructions.as_deref() {
706 prompt.push_str("\nProfile instructions:\n");
707 prompt.push_str(instructions);
708 }
709 }
710
711 prompt
712 }
713
714 fn resolve_task_agent_profile<'a>(
715 task_spec: &FleetTaskSpec,
716 agent_profiles: &'a [AgentProfile],
717 ) -> Result<Option<&'a AgentProfile>> {
718 let Some(profile_id) = task_spec
719 .worker
720 .as_ref()
721 .and_then(|worker| worker.agent_profile.as_deref())
722 .map(str::trim)
723 .filter(|id| !id.is_empty())
724 else {
725 return Ok(None);
726 };
727 let Some(profile) = agent_profiles
728 .iter()
729 .find(|profile| profile.id == profile_id)
730 else {
731 bail!(
732 "fleet task {} references unknown agent profile {profile_id:?}",
733 task_spec.id
734 );
735 };
736 Ok(Some(profile))
737 }
738
739 fn effective_fleet_role(
740 worker_profile: Option<&FleetTaskWorkerProfile>,
741 agent_profile: Option<&AgentProfile>,
742 ) -> Option<String> {
743 effective_fleet_role_with_source(worker_profile, agent_profile).0
744 }
745
746 fn effective_fleet_role_with_source(
747 worker_profile: Option<&FleetTaskWorkerProfile>,
748 agent_profile: Option<&AgentProfile>,
749 ) -> (Option<String>, Option<&'static str>) {
750 worker_profile
751 .and_then(|worker| worker.role.as_deref())
752 .map(str::trim)
753 .filter(|role| !role.is_empty())
754 .map(canonical_public_role_name)
755 .map(|role| (Some(role), Some("task.role")))
756 .unwrap_or_else(|| {
757 agent_profile
758 .map(|profile| {
759 (
760 Some(canonical_public_role_name(&profile.profile.role.name)),
761 Some("agent_profile.role"),
762 )
763 })
764 .unwrap_or((None, None))
765 })
766 }
767
768 fn effective_fleet_loadout(
769 worker_profile: Option<&FleetTaskWorkerProfile>,
770 agent_profile: Option<&AgentProfile>,
771 ) -> codewhale_config::FleetLoadout {
772 effective_fleet_loadout_with_source(worker_profile, agent_profile).0
773 }
774
775 fn effective_fleet_loadout_with_source(
776 worker_profile: Option<&FleetTaskWorkerProfile>,
777 agent_profile: Option<&AgentProfile>,
778 ) -> (codewhale_config::FleetLoadout, Option<&'static str>) {
779 if let Some(model_class) = worker_profile
780 .and_then(|worker| worker.model_class.as_deref())
781 .and_then(non_empty_trimmed)
782 {
783 return (
784 codewhale_config::FleetLoadout::from_name(model_class),
785 Some("task.model_class"),
786 );
787 }
788 if let Some(loadout) = worker_profile
789 .and_then(|worker| worker.loadout.as_deref())
790 .and_then(non_empty_trimmed)
791 {
792 return (
793 codewhale_config::FleetLoadout::from_name(loadout),
794 Some("task.loadout"),
795 );
796 }
797 if let Some(loadout) = agent_profile
798 .map(|profile| profile.profile.loadout.clone())
799 .filter(|loadout| *loadout != codewhale_config::FleetLoadout::Inherit)
800 {
801 return (loadout, Some("agent_profile.loadout"));
802 }
803 (codewhale_config::FleetLoadout::Inherit, None)
804 }
805
806 fn effective_fleet_model(
807 run_model: &str,
808 worker_profile: Option<&FleetTaskWorkerProfile>,
809 agent_profile: Option<&AgentProfile>,
810 ) -> String {
811 effective_fleet_model_with_source(run_model, worker_profile, agent_profile).0
812 }
813
814 fn effective_fleet_model_with_source(
815 run_model: &str,
816 worker_profile: Option<&FleetTaskWorkerProfile>,
817 agent_profile: Option<&AgentProfile>,
818 ) -> (String, &'static str) {
819 if let Some(model) = worker_profile
820 .and_then(|worker| worker.model.as_deref())
821 .and_then(non_empty_trimmed)
822 {
823 return (model.to_string(), "task.model");
824 }
825 if let Some(model) = agent_profile
826 .and_then(|profile| profile.profile.model.as_deref())
827 .and_then(non_empty_trimmed)
828 {
829 return (model.to_string(), "agent_profile.model");
830 }
831 (run_model.to_string(), "run.model")
832 }
833
834 /// The provider id a resolved agent profile EXPLICITLY pins, if any (#4093).
835 ///
836 /// This preserves user-named OpenAI-compatible custom providers such as
837 /// `lm-studio` instead of collapsing them through [`ApiProvider`]. Runtime
838 /// launch paths can set `Config.provider` to this exact id so the normal config
839 /// resolver finds `[providers.<id>]` (#3965).
840 ///
841 /// Returns `None` when no profile names a provider — never invents a DeepSeek
842 /// default — so launch paths can omit `--provider` and leave profile-less
843 /// workers on their own session default. EPIC #2608: never inferred from
844 /// `model`.
845 pub(crate) fn explicit_fleet_provider_id(agent_profile: Option<&AgentProfile>) -> Option<String> {
846 agent_profile
847 .and_then(|profile| profile.profile.provider.as_deref())
848 .map(str::trim)
849 .filter(|provider| !provider.is_empty())
850 .map(str::to_string)
851 }
852
853 /// The built-in provider a resolved agent profile EXPLICITLY pins, if any (#4093).
854 ///
855 /// This returns `None` (never the DeepSeek default) when no profile names a
856 /// provider, so call sites can leave `--provider` off the worker argv and
857 /// preserve today's behavior for profile-less / provider-less workers (they
858 /// resolve their provider from their own session default). EPIC #2608: never
859 /// inferred from `model`.
860 ///
861 /// `pub(crate)` so the interactive-TUI in-process spawn path
862 /// (`tools::subagent`) resolves the pinned provider from the SAME
863 /// explicit-only source as the headless `codewhale exec` launch route (#4193),
864 /// instead of re-deriving it and risking a second, divergent policy. User-named
865 /// custom providers intentionally return `None` here; launch paths that can
866 /// carry strings should use [`explicit_fleet_provider_id`].
867 pub(crate) fn explicit_fleet_provider(agent_profile: Option<&AgentProfile>) -> Option<ApiProvider> {
868 explicit_fleet_provider_id(agent_profile)
869 .as_deref()
870 .and_then(ApiProvider::parse)
871 }
872
873 pub(crate) fn effective_fleet_reasoning_effort(
874 agent_profile: Option<&AgentProfile>,
875 ) -> Option<String> {
876 agent_profile
877 .and_then(|profile| profile.profile.reasoning_effort.as_deref())
878 .map(str::trim)
879 .filter(|effort| !effort.is_empty())
880 .map(str::to_string)
881 }
882
883 fn effective_fleet_reasoning_effort_for_role(
884 worker_profile: Option<&FleetTaskWorkerProfile>,
885 agent_profile: Option<&AgentProfile>,
886 ) -> Option<String> {
887 effective_fleet_reasoning_effort(agent_profile).or_else(|| {
888 let role = effective_fleet_role(worker_profile, agent_profile);
889 WorkerRuntimeProfile::for_role(fleet_role_to_agent_type(role.as_deref())).reasoning_effort
890 })
891 }
892
893 /// The effective reasoning/thinking tier a Fleet worker should launch with.
894 ///
895 /// This is the launch-side twin of the receipt/runtime-profile field: an
896 /// explicit resolved AgentProfile tier wins, otherwise the selected role's
897 /// documented default applies. Task model overrides do not invent a tier.
898 pub(crate) fn fleet_worker_launch_reasoning_effort(
899 task_spec: &FleetTaskSpec,
900 agent_profiles: &[AgentProfile],
901 ) -> Option<String> {
902 let agent_profile = resolve_task_agent_profile(task_spec, agent_profiles)
903 .ok()
904 .flatten();
905 effective_fleet_reasoning_effort_for_role(task_spec.worker.as_ref(), agent_profile)
906 }
907
908 /// The route (model selector + optional explicit provider id) that a fleet
909 /// worker's actual `codewhale exec` subprocess should launch on (#4093 AC #4).
910 ///
911 /// This is the launch-side twin of [`resolve_fleet_route`] (the receipt): both
912 /// read the worker's model from the same task/profile/run precedence
913 /// ([`effective_fleet_model`]) and the provider from the same explicit-only
914 /// source ([`explicit_fleet_provider_id`]), so a worker whose profile is pinned
915 /// to provider B launches on provider B even when the parent session is on
916 /// provider A.
917 ///
918 /// - `model`: never empty in practice — falls back to `run_model` when neither
919 /// the task nor the profile pins a model, matching pre-#4093 dispatch.
920 /// - `provider`: `Some(provider_id)` ONLY when the resolved agent profile
921 /// explicitly pins a provider. `None` means "no provider authority" — the
922 /// caller omits `--provider` and the worker keeps its own session default,
923 /// preserving today's behavior for profile-less workers. Built-ins use their
924 /// canonical ids; user-named custom providers preserve the profile's id so
925 /// `codewhale exec --provider <id>` can resolve `[providers.<id>]`.
926 pub(crate) fn fleet_worker_launch_route(
927 task_spec: &FleetTaskSpec,
928 agent_profiles: &[AgentProfile],
929 run_model: &str,
930 ) -> (String, Option<String>) {
931 let agent_profile = resolve_task_agent_profile(task_spec, agent_profiles)
932 .ok()
933 .flatten();
934 let worker_profile = task_spec.worker.as_ref();
935 let model = effective_fleet_model(run_model, worker_profile, agent_profile);
936 let provider = explicit_fleet_provider_id(agent_profile);
937 (model, provider)
938 }
939
940 fn task_model_class_with_source(
941 worker_profile: Option<&FleetTaskWorkerProfile>,
942 ) -> (Option<String>, Option<&'static str>) {
943 worker_profile
944 .and_then(|worker| worker.model_class.as_deref())
945 .and_then(non_empty_trimmed)
946 .map(|model_class| (Some(model_class.to_string()), Some("task.model_class")))
947 .unwrap_or((None, None))
948 }
949
950 fn fleet_route_model_selector_with_source(
951 worker_profile: Option<&FleetTaskWorkerProfile>,
952 agent_profile: Option<&AgentProfile>,
953 session_model: Option<&str>,
954 ) -> (Option<String>, &'static str) {
955 // The session route (operator model) is the run-level fallback, matching
956 // the dispatch path where FleetManager::run_model() feeds
957 // `effective_fleet_model_with_source`. Empty/"auto" stays resolver-default.
958 let run_model = session_model
959 .map(str::trim)
960 .filter(|model| !model.is_empty())
961 .unwrap_or("auto");
962 let (model, source) =
963 effective_fleet_model_with_source(run_model, worker_profile, agent_profile);
964 if model.trim().is_empty() || model.eq_ignore_ascii_case("auto") {
965 (None, "resolver.default")
966 } else {
967 (Some(model), source)
968 }
969 }
970
971 /// Map a fleet role name to a `FleetRole`. Unknown roles default to `General`.
972 pub(crate) fn fleet_role_to_agent_type(role: Option<&str>) -> FleetRole {
973 match role {
974 Some("smoke-runner") => FleetRole::Verifier,
975 Some("scout") => FleetRole::Scout,
976 Some("read-only") => FleetRole::Scout,
977 Some("reviewer") => FleetRole::Reviewer,
978 Some("builder") => FleetRole::Builder,
979 Some("verifier") | Some("tester") => FleetRole::Verifier,
980 Some("planner") => FleetRole::Planner,
981 // Advisory counsel (#4752). `oracle` and `advisor` are compatibility
982 // aliases for the canonical public role name, `consultant`.
983 Some("consultant") | Some("oracle") | Some("advisor") => FleetRole::Consultant,
984 Some("explorer") => FleetRole::Scout,
985 // Coordination happens through delegation, which needs the full
986 // General surface (#fleet-roster cutover (v0.8.67)). The operator is
987 // the helm of the overall work (it assigns managers to Workflows);
988 // the manager is the middle manager of one Workflow. Both coordinate,
989 // so both get the General surface — explicitly, not by fall-through.
990 Some("manager") | Some("coordinator") | Some("operator") => FleetRole::Worker,
991 // Synthesis is read-only, no shell: it must never fall through to
992 // General's full-write posture (#fleet-roster cutover (v0.8.67)).
993 Some("synthesizer") | Some("summarizer") | Some("reducer") => FleetRole::Planner,
994 Some("general") | None => FleetRole::Worker,
995 Some(other) => {
996 // Try parsing as a FleetRole directly
997 FleetRole::from_str(other).unwrap_or(FleetRole::Worker)
998 }
999 }
1000 }
1001
1002 /// Runtime agent type for a roster member: role name first, falling back to
1003 /// the org-chart slot name when the role name is empty (#fleet-roster cutover
1004 /// (v0.8.67)).
1005 pub(crate) fn roster_member_agent_type(member: &AgentProfile) -> FleetRole {
1006 let role_name = member.profile.role.name.trim();
1007 if role_name.is_empty() {
1008 fleet_role_to_agent_type(Some(member.profile.slot.as_str()))
1009 } else {
1010 fleet_role_to_agent_type(Some(role_name))
1011 }
1012 }
1013
1014 /// Convert a fleet worker profile's tool list into an `AgentWorkerToolProfile`.
1015 fn fleet_tool_profile(profile: Option<&FleetTaskWorkerProfile>) -> AgentWorkerToolProfile {
1016 match profile {
1017 Some(p) if !p.tools.is_empty() => AgentWorkerToolProfile::Explicit(p.tools.clone()),
1018 _ => AgentWorkerToolProfile::Inherited,
1019 }
1020 }
1021
1022 fn fleet_worker_runtime_profile(
1023 agent_type: &FleetRole,
1024 tool_profile: &AgentWorkerToolProfile,
1025 model: &str,
1026 spawn_depth: u32,
1027 max_spawn_depth: u32,
1028 ) -> WorkerRuntimeProfile {
1029 let mut profile = WorkerRuntimeProfile::for_role(agent_type.clone());
1030 profile.tools = match tool_profile {
1031 AgentWorkerToolProfile::Inherited => ToolScope::Inherit,
1032 AgentWorkerToolProfile::Explicit(tools) => ToolScope::Explicit(tools.clone()),
1033 };
1034 profile.model = if model == "auto" {
1035 ModelRoute::Auto
1036 } else {
1037 ModelRoute::Fixed(model.to_string())
1038 };
1039 profile.max_spawn_depth = max_spawn_depth.saturating_sub(spawn_depth);
1040 profile.background = true;
1041 profile
1042 }
1043
1044 fn fleet_worker_runtime_profile_for_loadout(
1045 agent_type: &FleetRole,
1046 tool_profile: &AgentWorkerToolProfile,
1047 model: &str,
1048 spawn_depth: u32,
1049 max_spawn_depth: u32,
1050 loadout: &codewhale_config::FleetLoadout,
1051 model_source: &'static str,
1052 ) -> WorkerRuntimeProfile {
1053 let mut profile = fleet_worker_runtime_profile(
1054 agent_type,
1055 tool_profile,
1056 model,
1057 spawn_depth,
1058 max_spawn_depth,
1059 );
1060 profile.model = if matches!(model_source, "task.model" | "agent_profile.model") {
1061 fleet_model_route_for_loadout(model, &codewhale_config::FleetLoadout::Inherit)
1062 } else {
1063 fleet_model_route_for_loadout("auto", loadout)
1064 };
1065 profile
1066 }
1067
1068 fn non_empty_trimmed(value: &str) -> Option<&str> {
1069 let trimmed = value.trim();
1070 (!trimmed.is_empty()).then_some(trimmed)
1071 }
1072
1073 pub(crate) fn fleet_model_route_for_loadout(
1074 model: &str,
1075 loadout: &codewhale_config::FleetLoadout,
1076 ) -> ModelRoute {
1077 let model = model.trim();
1078 if !model.is_empty() && !model.eq_ignore_ascii_case("auto") {
1079 return ModelRoute::Fixed(model.to_string());
1080 }
1081 match loadout {
1082 codewhale_config::FleetLoadout::Inherit => ModelRoute::Inherit,
1083 codewhale_config::FleetLoadout::Fast => ModelRoute::Faster,
1084 codewhale_config::FleetLoadout::Custom(_) => ModelRoute::Auto,
1085 }
1086 }
1087
1088 /// Apply exec hardening to a worker spec from fleet config (#3027).
1089 ///
1090 /// Filters tools against allowed/disallowed lists, caps max_steps to
1091 /// config's max_turns, and returns the objective with system prompt
1092 /// appended when configured.
1093 pub fn apply_exec_hardening(
1094 mut spec: AgentWorkerSpec,
1095 exec: &codewhale_config::FleetExecConfig,
1096 ) -> AgentWorkerSpec {
1097 // Cap max_steps to config max_turns (0 means no cap).
1098 if exec.max_turns > 0 {
1099 spec.max_steps = spec.max_steps.min(exec.max_turns);
1100 }
1101 spec.max_spawn_depth = exec
1102 .max_spawn_depth
1103 .min(codewhale_config::MAX_SPAWN_DEPTH_CEILING);
1104 spec.runtime_profile.max_spawn_depth = spec.max_spawn_depth.saturating_sub(spec.spawn_depth);
1105
1106 // Apply tool filtering
1107 if !exec.allowed_tools.is_empty() || !exec.disallowed_tools.is_empty() {
1108 spec.tool_profile = filter_tool_profile(&spec.tool_profile, exec);
1109 spec.runtime_profile.tools = match &spec.tool_profile {
1110 AgentWorkerToolProfile::Inherited => ToolScope::Inherit,
1111 AgentWorkerToolProfile::Explicit(tools) => ToolScope::Explicit(tools.clone()),
1112 };
1113 }
1114 // #4042: thread `FleetExecConfig.disallowed_tools` into the runtime profile's
1115 // deny-list so it is enforced at run time even for `Inherited` tool profiles,
1116 // which `filter_tool_profile` cannot narrow at spec time. Union with any
1117 // already-inherited entries (deny never relaxes). The subprocess Fleet exec
1118 // path separately passes `--disallowed-tools` on the CLI.
1119 for rule in &exec.disallowed_tools {
1120 if !spec.runtime_profile.denied_tools.contains(rule) {
1121 spec.runtime_profile.denied_tools.push(rule.clone());
1122 }
1123 }
1124
1125 // Append system prompt
1126 if !exec.append_system_prompt.is_empty() {
1127 spec.objective = format!(
1128 "{}\n\n[Policy]\n{}",
1129 spec.objective, exec.append_system_prompt
1130 );
1131 }
1132
1133 spec
1134 }
1135
1136 pub(crate) fn fleet_effective_permissions_from_worker_spec(
1137 spec: &AgentWorkerSpec,
1138 ) -> FleetEffectivePermissions {
1139 fleet_effective_permissions_from_runtime_profile(&spec.runtime_profile, None)
1140 }
1141
1142 pub(crate) fn fleet_effective_permissions_for_task(
1143 task_spec: &FleetTaskSpec,
1144 agent_profiles: &[AgentProfile],
1145 spec: &AgentWorkerSpec,
1146 ) -> FleetEffectivePermissions {
1147 let agent_profile = resolve_task_agent_profile(task_spec, agent_profiles)
1148 .ok()
1149 .flatten();
1150 fleet_effective_permissions_from_runtime_profile(&spec.runtime_profile, agent_profile)
1151 }
1152
1153 pub(crate) fn fleet_effective_permissions_from_runtime_profile(
1154 profile: &WorkerRuntimeProfile,
1155 agent_profile: Option<&AgentProfile>,
1156 ) -> FleetEffectivePermissions {
1157 FleetEffectivePermissions {
1158 write: profile.permissions.write,
1159 network: profile.permissions.network,
1160 shell: shell_policy_label(profile.shell).to_string(),
1161 tool_scope: tool_scope_label(&profile.tools).to_string(),
1162 tools: match &profile.tools {
1163 ToolScope::Inherit => Vec::new(),
1164 ToolScope::Explicit(tools) => tools.clone(),
1165 },
1166 background: profile.background,
1167 max_spawn_depth: profile.max_spawn_depth,
1168 profile_id: agent_profile.map(|profile| profile.id.clone()),
1169 profile_origin: agent_profile
1170 .map(|profile| profile_origin_label(profile.origin).to_string()),
1171 source: "worker_runtime_profile".to_string(),
1172 }
1173 }
1174
1175 /// Return a truthful dispatch warning when a brief asks for network-backed
1176 /// verification but the selected Fleet role cannot use the network.
1177 pub(crate) fn network_posture_warning_for_task(
1178 task: &FleetTaskSpec,
1179 agent_profiles: &[AgentProfile],
1180 session_model: Option<&str>,
1181 ) -> Option<String> {
1182 let brief = format!(
1183 "{}\n{}\n{}",
1184 task.name,
1185 task.objective.as_deref().unwrap_or_default(),
1186 task.instructions
1187 );
1188 let lower = brief.to_ascii_lowercase();
1189 let asks_for_network = [
1190 "gh ",
1191 "gh\n",
1192 "github",
1193 "curl ",
1194 "wget ",
1195 "http://",
1196 "https://",
1197 "network",
1198 "web search",
1199 "check ci",
1200 "check the pr",
1201 "check the issue",
1202 ]
1203 .iter()
1204 .any(|needle| lower.contains(needle));
1205 if !asks_for_network {
1206 return None;
1207 }
1208
1209 let agent_profile = resolve_task_agent_profile(task, agent_profiles)
1210 .ok()
1211 .flatten();
1212 let worker_profile = task.worker.as_ref();
1213 let role = effective_fleet_role(worker_profile, agent_profile);
1214 let agent_type = fleet_role_to_agent_type(role.as_deref());
1215 let tool_profile = fleet_tool_profile(worker_profile);
1216 let (model, model_source) = effective_fleet_model_with_source(
1217 session_model.unwrap_or("auto"),
1218 worker_profile,
1219 agent_profile,
1220 );
1221 let loadout = effective_fleet_loadout(worker_profile, agent_profile);
1222 let runtime = fleet_worker_runtime_profile_for_loadout(
1223 &agent_type,
1224 &tool_profile,
1225 &model,
1226 0,
1227 codewhale_config::FleetExecConfig::default().max_spawn_depth,
1228 &loadout,
1229 model_source,
1230 );
1231 if runtime.permissions.network {
1232 return None;
1233 }
1234
1235 Some(format!(
1236 "Fleet task `{}` mentions network-backed verification, but role `{}` has network=off and shell={}. Dispatch a `worker` role with shell `read_only` for gh/curl evidence, or revise the brief.",
1237 task.id,
1238 role.as_deref().unwrap_or("worker"),
1239 shell_policy_label(runtime.shell),
1240 ))
1241 }
1242
1243 fn profile_origin_label(origin: crate::fleet::roster::ProfileOrigin) -> &'static str {
1244 match origin {
1245 crate::fleet::roster::ProfileOrigin::BuiltIn => "built_in",
1246 crate::fleet::roster::ProfileOrigin::Config => "config",
1247 crate::fleet::roster::ProfileOrigin::Personal => "personal",
1248 crate::fleet::roster::ProfileOrigin::Workspace => "workspace",
1249 }
1250 }
1251
1252 fn shell_policy_label(shell: crate::worker_profile::ShellPolicy) -> &'static str {
1253 match shell {
1254 crate::worker_profile::ShellPolicy::None => "none",
1255 crate::worker_profile::ShellPolicy::ReadOnly => "read_only",
1256 crate::worker_profile::ShellPolicy::Full => "full",
1257 }
1258 }
1259
1260 fn tool_scope_label(tools: &ToolScope) -> &'static str {
1261 match tools {
1262 ToolScope::Inherit => "inherit",
1263 ToolScope::Explicit(_) => "explicit",
1264 }
1265 }
1266
1267 /// Filter a tool profile against allowed/disallowed lists.
1268 fn filter_tool_profile(
1269 profile: &AgentWorkerToolProfile,
1270 exec: &codewhale_config::FleetExecConfig,
1271 ) -> AgentWorkerToolProfile {
1272 match profile {
1273 AgentWorkerToolProfile::Explicit(tools) => {
1274 let filtered: Vec<String> = tools
1275 .iter()
1276 .filter(|t| {
1277 // If allowed_tools is non-empty, only keep tools in the list
1278 if !exec.allowed_tools.is_empty() && !exec.allowed_tools.contains(t) {
1279 return false;
1280 }
1281 // Disallowed tools always win
1282 !exec.disallowed_tools.contains(t)
1283 })
1284 .cloned()
1285 .collect();
1286 AgentWorkerToolProfile::Explicit(filtered)
1287 }
1288 AgentWorkerToolProfile::Inherited => {
1289 // Inherited profiles can't be filtered at spec time;
1290 // the sub-agent spawn path applies tool filtering.
1291 AgentWorkerToolProfile::Inherited
1292 }
1293 }
1294 }
1295
1296 #[cfg(test)]
1297 mod tests {
1298 use super::*;
1299 use codewhale_protocol::fleet::{FleetHostSpec, FleetWorkspaceRequirements};
1300 use std::path::{Path, PathBuf};
1301
1302 #[test]
1303 fn worker_workspace_isolation_requires_linked_worktree_outside_manager() {
1304 let tmp = tempfile::tempdir().expect("tempdir");
1305 let manager = tmp.path().join("manager");
1306 std::fs::create_dir_all(manager.join("sub")).expect("manager dirs");
1307 let worktree = tmp.path().join("worktrees").join("wt-1");
1308 std::fs::create_dir_all(&worktree).expect("worktree dir");
1309
1310 assert!(!worker_workspace_is_isolated(&manager, &manager));
1311 assert!(!worker_workspace_is_isolated(
1312 &manager,
1313 &manager.join("sub")
1314 ));
1315 // An external directory without a worktree gitfile stays shared.
1316 assert!(!worker_workspace_is_isolated(&manager, &worktree));
1317
1318 std::fs::write(
1319 worktree.join(".git"),
1320 "gitdir: /elsewhere/.git/worktrees/wt-1\n",
1321 )
1322 .expect("gitfile");
1323 assert!(worker_workspace_is_isolated(&manager, &worktree));
1324 }
1325
1326 fn fleet_task(id: &str, worker: Option<FleetTaskWorkerProfile>) -> FleetTaskSpec {
1327 FleetTaskSpec {
1328 id: id.to_string(),
1329 name: id.to_string(),
1330 description: None,
1331 objective: Some(format!("Complete {id}")),
1332 instructions: format!("do {id}"),
1333 worker,
1334 workspace: Some(FleetWorkspaceRequirements {
1335 root: Some(PathBuf::from(".")),
1336 required_files: Vec::new(),
1337 writable_paths: vec![PathBuf::from(".")],
1338 environment: None,
1339 }),
1340 input_files: Vec::new(),
1341 context: Vec::new(),
1342 budget: None,
1343 tags: Vec::new(),
1344 expected_artifacts: Vec::new(),
1345 scorer: None,
1346 retry_policy: None,
1347 alert_policy: None,
1348 timeout_seconds: None,
1349 metadata: Default::default(),
1350 }
1351 }
1352
1353 #[test]
1354 fn write_capable_fleet_worker_requires_and_persists_a_bounded_claim() {
1355 let worker = FleetWorkerSpec {
1356 id: "worker-1".to_string(),
1357 name: "Worker".to_string(),
1358 host: FleetHostSpec::Local,
1359 trust_level: None,
1360 labels: Default::default(),
1361 capabilities: vec![],
1362 max_concurrent_tasks: None,
1363 };
1364 let mut unscoped = fleet_task("write", None);
1365 unscoped.workspace = None;
1366 let error = fleet_task_to_worker_spec_with_profiles(
1367 "worker-1",
1368 "run-1",
1369 &unscoped,
1370 &worker,
1371 "auto",
1372 Path::new("/tmp"),
1373 Path::new("/tmp"),
1374 &[],
1375 None,
1376 )
1377 .expect_err("unscoped Fleet writer must fail before registration");
1378 assert!(error.to_string().contains("declares no"), "{error:#}");
1379
1380 let scoped = fleet_task_to_worker_spec_with_profiles(
1381 "worker-1",
1382 "run-1",
1383 &fleet_task("write", None),
1384 &worker,
1385 "auto",
1386 Path::new("/tmp"),
1387 Path::new("/tmp"),
1388 &[],
1389 None,
1390 )
1391 .expect("bounded Fleet writer");
1392 let manifest = scoped.launch_manifest.expect("launch manifest");
1393 assert_eq!(manifest.child_id, "worker-1");
1394 assert_eq!(manifest.writable_roots, ["."]);
1395 assert_eq!(manifest.prompt, scoped.objective);
1396 }
1397
1398 #[test]
1399 fn fleet_claim_roots_share_one_manager_workspace_namespace() {
1400 let worker = FleetWorkerSpec {
1401 id: "worker-1".to_string(),
1402 name: "Worker".to_string(),
1403 host: FleetHostSpec::Local,
1404 trust_level: None,
1405 labels: Default::default(),
1406 capabilities: vec![],
1407 max_concurrent_tasks: None,
1408 };
1409 let mut nested = fleet_task("nested", None);
1410 nested.workspace = Some(FleetWorkspaceRequirements {
1411 root: Some(PathBuf::from("pkg-a")),
1412 writable_paths: vec![PathBuf::from("src")],
1413 ..FleetWorkspaceRequirements::default()
1414 });
1415 let mut root = fleet_task("root", None);
1416 root.workspace = Some(FleetWorkspaceRequirements {
1417 root: Some(PathBuf::from(".")),
1418 writable_paths: vec![PathBuf::from("pkg-a/src")],
1419 ..FleetWorkspaceRequirements::default()
1420 });
1421
1422 let nested_spec = fleet_task_to_worker_spec_with_profiles(
1423 "worker-1",
1424 "run-1",
1425 &nested,
1426 &worker,
1427 "auto",
1428 Path::new("/repo/pkg-a"),
1429 Path::new("/repo/pkg-a"),
1430 &[],
1431 None,
1432 )
1433 .unwrap();
1434 let root_spec = fleet_task_to_worker_spec_with_profiles(
1435 "worker-2",
1436 "run-1",
1437 &root,
1438 &worker,
1439 "auto",
1440 Path::new("/repo"),
1441 Path::new("/repo"),
1442 &[],
1443 None,
1444 )
1445 .unwrap();
1446 assert_eq!(
1447 nested_spec.launch_manifest.unwrap().writable_roots,
1448 ["pkg-a/src"]
1449 );
1450 assert_eq!(
1451 root_spec.launch_manifest.unwrap().writable_roots,
1452 ["pkg-a/src"]
1453 );
1454 assert_eq!(fleet_runtime_write_roots(&nested).unwrap(), ["src"]);
1455 }
1456
1457 #[test]
1458 fn fleet_manifest_rejects_control_characters_before_lease() {
1459 let worker = FleetWorkerSpec {
1460 id: "worker-1".to_string(),
1461 name: "Worker".to_string(),
1462 host: FleetHostSpec::Local,
1463 trust_level: None,
1464 labels: Default::default(),
1465 capabilities: vec![],
1466 max_concurrent_tasks: None,
1467 };
1468 let mut bad_contract = fleet_task("bad-contract", None);
1469 bad_contract.metadata.insert(
1470 "coordination_contracts".to_string(),
1471 serde_json::json!(["api\ncontract"]),
1472 );
1473 assert!(
1474 fleet_task_to_worker_spec_with_profiles(
1475 "worker-1",
1476 "run-1",
1477 &bad_contract,
1478 &worker,
1479 "auto",
1480 Path::new("/repo"),
1481 Path::new("/repo"),
1482 &[],
1483 None,
1484 )
1485 .unwrap_err()
1486 .to_string()
1487 .contains("one non-empty line")
1488 );
1489
1490 let mut bad_path = fleet_task("bad-path", None);
1491 bad_path.workspace.as_mut().unwrap().writable_paths = vec![PathBuf::from("src\nother")];
1492 assert!(
1493 fleet_task_to_worker_spec_with_profiles(
1494 "worker-1",
1495 "run-1",
1496 &bad_path,
1497 &worker,
1498 "auto",
1499 Path::new("/repo"),
1500 Path::new("/repo"),
1501 &[],
1502 None,
1503 )
1504 .unwrap_err()
1505 .to_string()
1506 .contains("one repo-relative line")
1507 );
1508 }
1509
1510 fn worker_profile(
1511 agent_profile: Option<&str>,
1512 role: Option<&str>,
1513 loadout: Option<&str>,
1514 model_class: Option<&str>,
1515 model: Option<&str>,
1516 tools: Vec<&str>,
1517 ) -> FleetTaskWorkerProfile {
1518 FleetTaskWorkerProfile {
1519 agent_profile: agent_profile.map(str::to_string),
1520 role: role.map(str::to_string),
1521 loadout: loadout.map(str::to_string),
1522 model_class: model_class.map(str::to_string),
1523 model: model.map(str::to_string),
1524 tool_profile: None,
1525 tools: tools.into_iter().map(str::to_string).collect(),
1526 capabilities: Vec::new(),
1527 }
1528 }
1529
1530 fn agent_profile(
1531 id: &str,
1532 role: &str,
1533 instructions: Option<&str>,
1534 loadout: codewhale_config::FleetLoadout,
1535 ) -> AgentProfile {
1536 AgentProfile {
1537 id: id.to_string(),
1538 display_name: Some(format!("{role} profile")),
1539 description: Some(format!("{role} description")),
1540 profile: codewhale_config::FleetProfile {
1541 slot: codewhale_config::FleetSlot::from_name(role),
1542 role: codewhale_config::FleetRole {
1543 name: role.to_string(),
1544 description: Some(format!("{role} role")),
1545 instructions: instructions.map(str::to_string),
1546 },
1547 loadout,
1548 model: None,
1549 provider: None,
1550 reasoning_effort: None,
1551 permissions: codewhale_config::FleetProfilePermissions::default(),
1552 delegation: codewhale_config::FleetDelegationHints::default(),
1553 },
1554 source: std::path::PathBuf::from(format!("{id}.toml")),
1555 origin: crate::fleet::roster::ProfileOrigin::Workspace,
1556 }
1557 }
1558
1559 #[test]
1560 fn fleet_role_smoke_runner_maps_to_verifier() {
1561 assert_eq!(
1562 fleet_role_to_agent_type(Some("smoke-runner")),
1563 FleetRole::Verifier
1564 );
1565 }
1566
1567 #[test]
1568 fn fleet_role_read_only_maps_to_explore() {
1569 assert_eq!(
1570 fleet_role_to_agent_type(Some("read-only")),
1571 FleetRole::Scout
1572 );
1573 }
1574
1575 #[test]
1576 fn fleet_role_reviewer_maps_to_review() {
1577 assert_eq!(
1578 fleet_role_to_agent_type(Some("reviewer")),
1579 FleetRole::Reviewer
1580 );
1581 }
1582
1583 #[test]
1584 fn fleet_role_builder_maps_to_implementer() {
1585 assert_eq!(
1586 fleet_role_to_agent_type(Some("builder")),
1587 FleetRole::Builder
1588 );
1589 }
1590
1591 #[test]
1592 fn fleet_role_none_maps_to_general() {
1593 assert_eq!(fleet_role_to_agent_type(None), FleetRole::Worker);
1594 }
1595
1596 #[test]
1597 fn fleet_role_manager_and_coordinator_map_to_general() {
1598 assert_eq!(fleet_role_to_agent_type(Some("manager")), FleetRole::Worker);
1599 assert_eq!(
1600 fleet_role_to_agent_type(Some("coordinator")),
1601 FleetRole::Worker
1602 );
1603 }
1604
1605 #[test]
1606 fn fleet_role_operator_maps_to_general_explicitly() {
1607 // The operator coordinates the overall work (assigns managers to
1608 // workflows), so it needs the full General surface — by an explicit
1609 // match arm, not the unknown-role fall-through.
1610 assert_eq!(
1611 fleet_role_to_agent_type(Some("operator")),
1612 FleetRole::Worker
1613 );
1614 }
1615
1616 #[test]
1617 fn consultant_and_legacy_advisory_aliases_share_the_consultant_posture() {
1618 for role in ["consultant", "oracle", "advisor"] {
1619 assert_eq!(
1620 fleet_role_to_agent_type(Some(role)),
1621 FleetRole::Consultant,
1622 "role {role}"
1623 );
1624 }
1625 }
1626
1627 #[test]
1628 fn fleet_role_synthesizer_family_maps_to_read_only_plan() {
1629 // A synthesizer must never fall through to General's full-write
1630 // posture; Plan is read-only with no shell.
1631 for role in ["synthesizer", "summarizer", "reducer"] {
1632 assert_eq!(
1633 fleet_role_to_agent_type(Some(role)),
1634 FleetRole::Planner,
1635 "role {role}"
1636 );
1637 }
1638 }
1639
1640 #[test]
1641 fn roster_member_agent_type_uses_role_then_slot() {
1642 let member = agent_profile(
1643 "synthesizer",
1644 "synthesizer",
1645 None,
1646 codewhale_config::FleetLoadout::Fast,
1647 );
1648 assert_eq!(roster_member_agent_type(&member), FleetRole::Planner);
1649
1650 let mut slot_only = agent_profile(
1651 "custom-summarizer",
1652 "summarizer",
1653 None,
1654 codewhale_config::FleetLoadout::Inherit,
1655 );
1656 slot_only.profile.role.name = String::new();
1657 assert_eq!(
1658 slot_only.profile.slot,
1659 codewhale_config::FleetSlot::Summarizer
1660 );
1661 assert_eq!(roster_member_agent_type(&slot_only), FleetRole::Planner);
1662 }
1663
1664 #[test]
1665 fn unknown_role_maps_to_general() {
1666 assert_eq!(
1667 fleet_role_to_agent_type(Some("nonexistent-role")),
1668 FleetRole::Worker
1669 );
1670 }
1671
1672 #[test]
1673 fn resolve_fleet_route_mints_secret_free_snapshot_from_resolver() {
1674 let task = fleet_task(
1675 "route-1",
1676 Some(worker_profile(
1677 None,
1678 Some("builder"),
1679 Some("fast"),
1680 None,
1681 None,
1682 vec!["read_file"],
1683 )),
1684 );
1685 let route =
1686 resolve_fleet_route(&task, &[], None).expect("default route should resolve offline");
1687
1688 // Honest, non-empty route shape from the resolver.
1689 assert!(!route.provider_id.is_empty());
1690 assert!(!route.provider_kind.is_empty());
1691 assert!(!route.wire_model_id.is_empty());
1692 assert_eq!(route.protocol, "chat_completions");
1693 assert_eq!(route.role.as_deref(), Some("builder"));
1694 assert_eq!(route.loadout.as_deref(), Some("fast"));
1695 assert_eq!(route.model_class, None);
1696 assert_eq!(route.model_route.as_deref(), Some("faster"));
1697 assert_eq!(route.reasoning_effort, None);
1698 assert_eq!(route.role_source.as_deref(), Some("task.role"));
1699 assert_eq!(route.loadout_source.as_deref(), Some("task.loadout"));
1700 assert_eq!(route.model_class_source, None);
1701 assert_eq!(route.model_source.as_deref(), Some("resolver.default"));
1702 assert_eq!(route.source, "resolver");
1703
1704 // No-secrets: the serialized snapshot carries no credential markers.
1705 let json = serde_json::to_string(&route).unwrap();
1706 let haystack = json.to_ascii_lowercase();
1707 for needle in [
1708 "api_key",
1709 "apikey",
1710 "api-key",
1711 "authorization",
1712 "bearer ",
1713 "auth_token",
1714 "auth-token",
1715 "password",
1716 "credential",
1717 "sk-ant-",
1718 "sk-proj-",
1719 "sk-or-",
1720 "secret",
1721 ] {
1722 assert!(
1723 !haystack.contains(needle),
1724 "resolved-route JSON must not contain secret marker {needle:?}: {json}"
1725 );
1726 }
1727 }
1728
1729 #[test]
1730 fn resolve_fleet_route_omits_inherit_loadout() {
1731 // No loadout/model_class intent → `inherit` collapses to None, never an
1732 // "inherit" string on the receipt.
1733 let task = fleet_task(
1734 "route-2",
1735 Some(worker_profile(
1736 None,
1737 Some("scout"),
1738 None,
1739 None,
1740 None,
1741 vec!["read_file"],
1742 )),
1743 );
1744 let route = resolve_fleet_route(&task, &[], None).expect("route should resolve");
1745 assert_eq!(route.role.as_deref(), Some("scout"));
1746 assert!(route.loadout.is_none());
1747 assert_eq!(route.loadout_source, None);
1748 assert_eq!(route.model_route.as_deref(), Some("inherit"));
1749 assert_eq!(route.model_source.as_deref(), Some("resolver.default"));
1750 }
1751
1752 #[test]
1753 fn advisory_task_aliases_emit_consultant_in_prompts_and_route_receipts() {
1754 for alias in ["oracle", "advisor"] {
1755 let task = fleet_task(
1756 &format!("legacy-{alias}"),
1757 Some(worker_profile(
1758 None,
1759 Some(alias),
1760 None,
1761 None,
1762 None,
1763 vec!["read_file"],
1764 )),
1765 );
1766
1767 let prompt = fleet_task_prompt(&task);
1768 assert!(
1769 prompt.contains("Fleet member (consultant)"),
1770 "prompt must canonicalize {alias}: {prompt}"
1771 );
1772 assert!(
1773 !prompt.contains(&format!("Fleet member ({alias})")),
1774 "prompt must not emit compatibility alias {alias}: {prompt}"
1775 );
1776
1777 let resolved = resolve_fleet_route(&task, &[], None)
1778 .expect("compatibility role should resolve a receipt route");
1779 assert_eq!(resolved.role.as_deref(), Some("consultant"));
1780 assert_eq!(resolved.role_source.as_deref(), Some("task.role"));
1781
1782 let reported = resolve_fleet_route_from_worker_report(
1783 &task,
1784 &[],
1785 None,
1786 "deepseek",
1787 None,
1788 "deepseek-v4-pro",
1789 )
1790 .expect("worker-reported route should retain canonical role metadata");
1791 assert_eq!(reported.role.as_deref(), Some("consultant"));
1792 assert_eq!(reported.role_source.as_deref(), Some("task.role"));
1793 }
1794 }
1795
1796 #[test]
1797 fn resolve_fleet_route_records_model_class_and_profile_sources() {
1798 let mut profile = agent_profile(
1799 "audit",
1800 "reviewer",
1801 None,
1802 codewhale_config::FleetLoadout::Inherit,
1803 );
1804 profile.profile.model = Some("deepseek-v4-flash".to_string());
1805 let task = fleet_task(
1806 "route-profile",
1807 Some(worker_profile(
1808 Some("audit"),
1809 None,
1810 None,
1811 Some("balanced"),
1812 None,
1813 vec!["read_file"],
1814 )),
1815 );
1816 let route =
1817 resolve_fleet_route(&task, &[profile], None).expect("profile route should resolve");
1818
1819 assert_eq!(route.role.as_deref(), Some("reviewer"));
1820 assert_eq!(route.role_source.as_deref(), Some("agent_profile.role"));
1821 assert_eq!(route.loadout.as_deref(), Some("balanced"));
1822 assert_eq!(route.loadout_source.as_deref(), Some("task.model_class"));
1823 assert_eq!(route.model_class.as_deref(), Some("balanced"));
1824 assert_eq!(
1825 route.model_class_source.as_deref(),
1826 Some("task.model_class")
1827 );
1828 assert_eq!(route.model_source.as_deref(), Some("agent_profile.model"));
1829 assert_eq!(route.model_route.as_deref(), Some("fixed"));
1830 assert_eq!(route.wire_model_id, "deepseek-v4-flash");
1831 assert_eq!(route.reasoning_effort, None);
1832 }
1833
1834 #[test]
1835 fn fleet_tool_profile_empty_uses_inherited() {
1836 let profile = FleetTaskWorkerProfile {
1837 agent_profile: None,
1838 role: None,
1839 loadout: None,
1840 model_class: None,
1841 model: None,
1842 tool_profile: None,
1843 tools: vec![],
1844 capabilities: vec![],
1845 };
1846 assert_eq!(
1847 fleet_tool_profile(Some(&profile)),
1848 AgentWorkerToolProfile::Inherited
1849 );
1850 }
1851
1852 #[test]
1853 fn fleet_tool_profile_explicit_passes_tools() {
1854 let profile = FleetTaskWorkerProfile {
1855 agent_profile: None,
1856 role: None,
1857 loadout: None,
1858 model_class: None,
1859 model: None,
1860 tool_profile: None,
1861 tools: vec!["cargo".to_string(), "git".to_string()],
1862 capabilities: vec![],
1863 };
1864 assert_eq!(
1865 fleet_tool_profile(Some(&profile)),
1866 AgentWorkerToolProfile::Explicit(vec!["cargo".to_string(), "git".to_string()])
1867 );
1868 }
1869
1870 #[test]
1871 fn network_brief_warns_for_networkless_reviewer_but_not_worker() {
1872 let reviewer = fleet_task(
1873 "triage",
1874 Some(worker_profile(
1875 None,
1876 Some("reviewer"),
1877 None,
1878 None,
1879 None,
1880 vec!["read_file"],
1881 )),
1882 );
1883 let mut reviewer = reviewer;
1884 reviewer.instructions = "Use gh to check the PR and report CI evidence.".to_string();
1885 // Scout/reviewer lanes now ship the recon posture (network reach,
1886 // bounded verification surface; see worker_profile::for_role), so a
1887 // network-dependent reviewer brief no longer warns by default.
1888 assert!(
1889 network_posture_warning_for_task(&reviewer, &[], None).is_none(),
1890 "reviewer recon posture must not warn for a gh brief"
1891 );
1892
1893 // A genuinely network-less role (planner: analysis only, no shell)
1894 // still warns for the same brief.
1895 let mut planner = reviewer.clone();
1896 planner.worker.as_mut().unwrap().role = Some("planner".to_string());
1897 let warning = network_posture_warning_for_task(&planner, &[], None)
1898 .expect("network-dependent planner brief should warn");
1899 assert!(warning.contains("network=off"));
1900
1901 let mut worker = reviewer.clone();
1902 worker.worker.as_mut().unwrap().role = Some("worker".to_string());
1903 assert!(network_posture_warning_for_task(&worker, &[], None).is_none());
1904 }
1905
1906 #[test]
1907 fn non_network_brief_does_not_warn_for_networkless_role() {
1908 let task = fleet_task(
1909 "local-review",
1910 Some(worker_profile(
1911 None,
1912 Some("reviewer"),
1913 None,
1914 None,
1915 None,
1916 vec!["read_file"],
1917 )),
1918 );
1919 assert!(network_posture_warning_for_task(&task, &[], None).is_none());
1920 }
1921
1922 #[test]
1923 fn fleet_task_prompt_includes_instructions_context_and_input_files() {
1924 let task = FleetTaskSpec {
1925 id: "review".to_string(),
1926 name: "Review protocol".to_string(),
1927 description: None,
1928 objective: Some("Find protocol regressions".to_string()),
1929 instructions: "Read the fleet protocol and report issues.".to_string(),
1930 worker: None,
1931 workspace: None,
1932 input_files: vec![std::path::PathBuf::from("crates/protocol/src/fleet.rs")],
1933 context: vec!["Keep the report concise.".to_string()],
1934 budget: None,
1935 tags: vec![],
1936 expected_artifacts: vec![],
1937 scorer: None,
1938 retry_policy: None,
1939 alert_policy: None,
1940 timeout_seconds: None,
1941 metadata: Default::default(),
1942 };
1943
1944 let prompt = fleet_task_prompt(&task);
1945
1946 assert!(prompt.contains("summoned as a Codewhale Fleet member (general)"));
1947 assert!(prompt.contains("Fleet operating contract:"));
1948 assert!(prompt.contains("keep sibling or topology assumptions out of your answer"));
1949 assert!(prompt.contains("Review protocol"));
1950 assert!(prompt.contains("Find protocol regressions"));
1951 assert!(prompt.contains("Read the fleet protocol and report issues."));
1952 assert!(prompt.contains("Keep the report concise."));
1953 assert!(prompt.contains("crates/protocol/src/fleet.rs"));
1954 }
1955
1956 #[test]
1957 fn fleet_worker_spec_resolves_agent_profile_role_prompt_and_loadout() {
1958 let profile = agent_profile(
1959 "reviewer",
1960 "reviewer",
1961 Some("Focus on regressions and missing tests."),
1962 codewhale_config::FleetLoadout::Custom("balanced".to_string()),
1963 );
1964 let task = fleet_task(
1965 "review",
1966 Some(worker_profile(
1967 Some("reviewer"),
1968 None,
1969 None,
1970 None,
1971 None,
1972 vec![],
1973 )),
1974 );
1975 let worker = FleetWorkerSpec {
1976 id: "worker-1".to_string(),
1977 name: "Worker".to_string(),
1978 host: FleetHostSpec::Local,
1979 trust_level: None,
1980 labels: Default::default(),
1981 capabilities: vec![],
1982 max_concurrent_tasks: None,
1983 };
1984
1985 let profiles = vec![profile];
1986 let spec = fleet_task_to_worker_spec_with_profiles(
1987 "worker-1",
1988 "run-1",
1989 &task,
1990 &worker,
1991 "auto",
1992 std::path::Path::new("/tmp"),
1993 std::path::Path::new("/tmp"),
1994 &profiles,
1995 None,
1996 )
1997 .unwrap();
1998
1999 assert_eq!(spec.role.as_deref(), Some("reviewer"));
2000 assert_eq!(spec.agent_type, FleetRole::Reviewer);
2001 assert!(
2002 spec.objective
2003 .contains("summoned as a Codewhale Fleet member (reviewer)")
2004 );
2005 assert!(spec.objective.contains("Fleet profile: reviewer"));
2006 assert!(
2007 spec.objective
2008 .contains("Focus on regressions and missing tests.")
2009 );
2010 assert_eq!(spec.runtime_profile.role, FleetRole::Reviewer);
2011 assert_eq!(spec.runtime_profile.model, ModelRoute::Auto);
2012
2013 let permissions = fleet_effective_permissions_for_task(&task, &profiles, &spec);
2014 assert_eq!(permissions.profile_id.as_deref(), Some("reviewer"));
2015 assert_eq!(permissions.profile_origin.as_deref(), Some("workspace"));
2016 assert_eq!(permissions.source, "worker_runtime_profile");
2017 }
2018
2019 #[test]
2020 fn role_only_consultant_aliases_keep_high_reasoning_and_locked_posture() {
2021 let worker = FleetWorkerSpec {
2022 id: "worker-1".to_string(),
2023 name: "Worker".to_string(),
2024 host: FleetHostSpec::Local,
2025 trust_level: None,
2026 labels: Default::default(),
2027 capabilities: vec![],
2028 max_concurrent_tasks: None,
2029 };
2030
2031 for parent_effort in [None, Some("low")] {
2032 for role in ["consultant", "oracle", "advisor"] {
2033 let task = fleet_task(
2034 &format!("advice-{role}"),
2035 Some(worker_profile(None, Some(role), None, None, None, vec![])),
2036 );
2037 let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker);
2038 parent.reasoning_effort = parent_effort.map(str::to_string);
2039 let spec = fleet_task_to_worker_spec_with_profiles(
2040 "worker-1",
2041 "run-1",
2042 &task,
2043 &worker,
2044 "deepseek-v4-pro",
2045 std::path::Path::new("/tmp"),
2046 std::path::Path::new("/tmp"),
2047 &[],
2048 Some(&parent),
2049 )
2050 .expect("role-only consultant should produce a worker spec");
2051
2052 assert_eq!(spec.role.as_deref(), Some("consultant"));
2053 assert_eq!(spec.agent_type, FleetRole::Consultant);
2054 assert_eq!(spec.model, "deepseek-v4-pro", "session model is inherited");
2055 assert_eq!(spec.runtime_profile.model, ModelRoute::Inherit);
2056 assert_eq!(
2057 spec.runtime_profile.provider, None,
2058 "provider is not invented"
2059 );
2060 assert_eq!(
2061 spec.runtime_profile.reasoning_effort.as_deref(),
2062 Some("high"),
2063 "role={role}, parent={parent_effort:?}"
2064 );
2065 assert!(!spec.runtime_profile.permissions.write);
2066 assert!(!spec.runtime_profile.permissions.network);
2067 assert_eq!(
2068 spec.runtime_profile.shell,
2069 crate::worker_profile::ShellPolicy::None
2070 );
2071 assert_eq!(
2072 fleet_worker_launch_reasoning_effort(&task, &[]).as_deref(),
2073 Some("high")
2074 );
2075 let route = resolve_fleet_route(&task, &[], Some("deepseek-v4-pro"))
2076 .expect("receipt route resolves");
2077 assert_eq!(route.role.as_deref(), Some("consultant"));
2078 assert_eq!(route.reasoning_effort.as_deref(), Some("high"));
2079 }
2080 }
2081 }
2082
2083 #[test]
2084 fn fleet_worker_spec_inherits_session_run_model_when_unpinned() {
2085 // No task-level model, no roster profile model: the run model (the
2086 // session route — the operator's model) must flow through to the
2087 // worker spec, so the model picked in /model is the model that runs.
2088 let task = fleet_task("build", None);
2089 let worker = FleetWorkerSpec {
2090 id: "worker-1".to_string(),
2091 name: "Worker".to_string(),
2092 host: FleetHostSpec::Local,
2093 trust_level: None,
2094 labels: Default::default(),
2095 capabilities: vec![],
2096 max_concurrent_tasks: None,
2097 };
2098
2099 let spec = fleet_task_to_worker_spec_with_profiles(
2100 "worker-1",
2101 "run-1",
2102 &task,
2103 &worker,
2104 "deepseek-v4-flash",
2105 std::path::Path::new("/tmp"),
2106 std::path::Path::new("/tmp"),
2107 &[],
2108 None,
2109 )
2110 .unwrap();
2111 assert_eq!(spec.model, "deepseek-v4-flash");
2112
2113 // Legacy headless callers with no session still get the auto sentinel.
2114 let legacy = fleet_task_to_worker_spec_with_profiles(
2115 "worker-1",
2116 "run-1",
2117 &task,
2118 &worker,
2119 "auto",
2120 std::path::Path::new("/tmp"),
2121 std::path::Path::new("/tmp"),
2122 &[],
2123 None,
2124 )
2125 .unwrap();
2126 assert_eq!(legacy.model, "auto");
2127 }
2128
2129 #[test]
2130 fn resolve_fleet_route_uses_session_model_as_run_fallback() {
2131 // Route receipts must agree with dispatch: when neither the task nor
2132 // a roster profile pins a model, the session route is the run-level
2133 // fallback and the receipt records it came from `run.model`.
2134 let task = fleet_task("route-session", None);
2135 let route = resolve_fleet_route(&task, &[], Some("deepseek-v4-flash"))
2136 .expect("session-model route should resolve offline");
2137 assert_eq!(route.model_source.as_deref(), Some("run.model"));
2138 assert_eq!(route.wire_model_id, "deepseek-v4-flash");
2139
2140 // Task/profile pins still win over the session route.
2141 let mut profile = agent_profile(
2142 "audit",
2143 "reviewer",
2144 None,
2145 codewhale_config::FleetLoadout::Inherit,
2146 );
2147 profile.profile.model = Some("deepseek-v4-pro".to_string());
2148 let pinned_task = fleet_task(
2149 "route-pinned",
2150 Some(worker_profile(
2151 Some("audit"),
2152 None,
2153 None,
2154 None,
2155 None,
2156 vec![],
2157 )),
2158 );
2159 let pinned = resolve_fleet_route(&pinned_task, &[profile], Some("deepseek-v4-flash"))
2160 .expect("pinned route should resolve");
2161 assert_eq!(pinned.model_source.as_deref(), Some("agent_profile.model"));
2162 assert_eq!(pinned.wire_model_id, "deepseek-v4-pro");
2163 }
2164
2165 #[test]
2166 fn validate_fleet_task_routes_rejects_unresolvable_providerless_pin() {
2167 // #4866 Luna failure mode: a profile pins a concrete model with no
2168 // explicit provider. The runtime never infers a provider from spelling,
2169 // so a model that does not resolve on the default provider must be
2170 // rejected at run creation with a clear error, not fail silently later.
2171 let mut profile = agent_profile(
2172 "builder-luna",
2173 "builder",
2174 None,
2175 codewhale_config::FleetLoadout::Inherit,
2176 );
2177 profile.profile.model = Some("gpt-5.6-luna".to_string());
2178 let task = fleet_task(
2179 "luna-build",
2180 Some(worker_profile(
2181 Some("builder-luna"),
2182 None,
2183 None,
2184 None,
2185 None,
2186 vec![],
2187 )),
2188 );
2189
2190 let err = validate_fleet_task_routes(&[task], &[profile], None, None)
2191 .expect_err("provider-less unresolvable pin must be rejected");
2192 let msg = err.to_string();
2193 assert!(msg.contains("gpt-5.6-luna"), "error names the model: {msg}");
2194 assert!(
2195 msg.contains("provider") || msg.contains("inherit"),
2196 "error tells the user how to fix it: {msg}"
2197 );
2198 }
2199
2200 #[test]
2201 fn validate_fleet_task_routes_rejects_known_foreign_providerless_pin() {
2202 let mut providers = crate::config::ProvidersConfig::default();
2203 providers.moonshot.api_key = Some("test-key".to_string());
2204 let config = Config {
2205 provider: Some("moonshot".to_string()),
2206 providers: Some(providers),
2207 ..Config::default()
2208 };
2209 let mut profile = agent_profile(
2210 "moonshot-builder",
2211 "builder",
2212 None,
2213 codewhale_config::FleetLoadout::Inherit,
2214 );
2215 profile.profile.model = Some("deepseek-v4-pro".to_string());
2216 let task = fleet_task(
2217 "foreign-model",
2218 Some(worker_profile(
2219 Some("moonshot-builder"),
2220 None,
2221 None,
2222 None,
2223 None,
2224 vec![],
2225 )),
2226 );
2227
2228 let err = validate_fleet_task_routes(
2229 std::slice::from_ref(&task),
2230 std::slice::from_ref(&profile),
2231 None,
2232 Some(&config),
2233 )
2234 .expect_err("known foreign model must fail before Fleet dispatch");
2235 let msg = err.to_string();
2236 assert!(msg.contains("deepseek-v4-pro"), "names model: {msg}");
2237 assert!(msg.contains("moonshot"), "names resolved route: {msg}");
2238 assert!(msg.contains("deepseek"), "names catalog owner: {msg}");
2239
2240 profile.profile.provider = Some("moonshot".to_string());
2241 validate_fleet_task_routes(&[task], &[profile], None, Some(&config))
2242 .expect("an explicit provider+model pair remains deliberate route intent");
2243 }
2244
2245 #[test]
2246 fn validate_fleet_task_routes_accepts_resolvable_pin_and_inherit() {
2247 // A real default-provider model resolves fine without an explicit pin.
2248 let mut good = agent_profile(
2249 "builder-ds",
2250 "builder",
2251 None,
2252 codewhale_config::FleetLoadout::Inherit,
2253 );
2254 good.profile.model = Some("deepseek-v4-flash".to_string());
2255 let good_task = fleet_task(
2256 "ds-build",
2257 Some(worker_profile(
2258 Some("builder-ds"),
2259 None,
2260 None,
2261 None,
2262 None,
2263 vec![],
2264 )),
2265 );
2266 validate_fleet_task_routes(&[good_task], &[good], None, None)
2267 .expect("resolvable default-provider model must pass");
2268
2269 // Inherit (no model pin) is never rejected.
2270 let inherit = agent_profile(
2271 "inherit-role",
2272 "builder",
2273 None,
2274 codewhale_config::FleetLoadout::Inherit,
2275 );
2276 let inherit_task = fleet_task(
2277 "inherit-build",
2278 Some(worker_profile(
2279 Some("inherit-role"),
2280 None,
2281 None,
2282 None,
2283 None,
2284 vec![],
2285 )),
2286 );
2287 validate_fleet_task_routes(&[inherit_task], &[inherit], None, None)
2288 .expect("inherit (no model pin) must pass");
2289 }
2290
2291 #[test]
2292 fn validate_fleet_task_routes_rejects_unsupported_thinking_tier() {
2293 let mut profile = agent_profile(
2294 "preview-builder",
2295 "builder",
2296 None,
2297 codewhale_config::FleetLoadout::Inherit,
2298 );
2299 profile.profile.model = Some("trinity-large-preview".to_string());
2300 profile.profile.provider = Some("arcee".to_string());
2301 profile.profile.reasoning_effort = Some("high".to_string());
2302 let task = fleet_task(
2303 "preview-build",
2304 Some(worker_profile(
2305 Some("preview-builder"),
2306 None,
2307 None,
2308 None,
2309 None,
2310 vec![],
2311 )),
2312 );
2313
2314 let err = validate_fleet_task_routes(&[task], &[profile], Some("deepseek-v4-flash"), None)
2315 .expect_err("unsupported thinking tier must fail before leasing");
2316 let message = err.to_string();
2317 assert!(message.contains("does not support thinking"), "{message}");
2318 assert!(message.contains("trinity-large-preview"), "{message}");
2319 }
2320
2321 #[test]
2322 fn validate_fleet_task_routes_accepts_thinking_capable_route() {
2323 let mut profile = agent_profile(
2324 "deep-builder",
2325 "builder",
2326 None,
2327 codewhale_config::FleetLoadout::Inherit,
2328 );
2329 profile.profile.model = Some("deepseek-v4-flash".to_string());
2330 profile.profile.reasoning_effort = Some("high".to_string());
2331 let task = fleet_task(
2332 "deep-build",
2333 Some(worker_profile(
2334 Some("deep-builder"),
2335 None,
2336 None,
2337 None,
2338 None,
2339 vec![],
2340 )),
2341 );
2342
2343 validate_fleet_task_routes(&[task], &[profile], Some("deepseek-v4-flash"), None)
2344 .expect("thinking-capable route must pass");
2345 }
2346
2347 #[test]
2348 fn validate_fleet_task_routes_keeps_non_explicit_thinking_modes_route_agnostic() {
2349 for effort in ["inherit", "auto", "off"] {
2350 let mut profile = agent_profile(
2351 "preview-builder",
2352 "builder",
2353 None,
2354 codewhale_config::FleetLoadout::Inherit,
2355 );
2356 profile.profile.model = Some("trinity-large-preview".to_string());
2357 profile.profile.provider = Some("arcee".to_string());
2358 profile.profile.reasoning_effort = Some(effort.to_string());
2359 let task = fleet_task(
2360 "preview-build",
2361 Some(worker_profile(
2362 Some("preview-builder"),
2363 None,
2364 None,
2365 None,
2366 None,
2367 vec![],
2368 )),
2369 );
2370
2371 validate_fleet_task_routes(&[task], &[profile], Some("deepseek-v4-flash"), None)
2372 .unwrap_or_else(|error| panic!("{effort} must remain valid: {error}"));
2373 }
2374 }
2375
2376 #[test]
2377 fn resolve_fleet_route_honors_explicit_profile_provider_not_the_default() {
2378 // EPIC #2608 / #4093: the resolved provider must come ONLY from the
2379 // profile's explicit `provider` field — never inferred from a
2380 // provider-shaped substring in `model`, and never the parent/session
2381 // route's provider. `deepseek-v4-flash` is deliberately DeepSeek-shaped
2382 // while the profile pins `openrouter`.
2383 let mut profile = agent_profile(
2384 "cross-provider",
2385 "scout",
2386 None,
2387 codewhale_config::FleetLoadout::Inherit,
2388 );
2389 profile.profile.model = Some("deepseek-v4-flash".to_string());
2390 profile.profile.provider = Some("openrouter".to_string());
2391 profile.profile.reasoning_effort = Some("max".to_string());
2392 let task = fleet_task(
2393 "route-cross-provider",
2394 Some(worker_profile(
2395 Some("cross-provider"),
2396 None,
2397 None,
2398 None,
2399 None,
2400 vec![],
2401 )),
2402 );
2403
2404 // The "parent"/session route is a completely different provider's
2405 // model, proving the resolved route does not fall back to it.
2406 let route = resolve_fleet_route(&task, &[profile], Some("deepseek-v4-pro"))
2407 .expect("cross-provider profile route should resolve");
2408
2409 assert_eq!(route.model_source.as_deref(), Some("agent_profile.model"));
2410
2411 // Resolving `openrouter` directly with the same selector is the
2412 // ground truth for what this route SHOULD produce — comparing
2413 // against it (rather than hardcoding a wire id) proves the profile's
2414 // provider actually drove resolution, whatever wire id/aggregator
2415 // mapping the resolver's catalog assigns.
2416 let openrouter_candidate = resolve_route_candidate(
2417 ApiProvider::Openrouter,
2418 Some("deepseek-v4-flash"),
2419 None,
2420 None,
2421 None,
2422 )
2423 .expect("openrouter should resolve the pinned model directly");
2424 assert_eq!(
2425 route.wire_model_id,
2426 openrouter_candidate.wire_model_id().as_str()
2427 );
2428 assert_eq!(
2429 route.provider_id,
2430 openrouter_candidate.provider_id().as_str()
2431 );
2432 assert_eq!(
2433 route.provider_kind,
2434 openrouter_candidate.provider_kind().as_str()
2435 );
2436 assert_eq!(route.reasoning_effort.as_deref(), Some("max"));
2437 // Differs from DeepSeek — the pre-#4093 hardcoded default AND the
2438 // parent/session's provider.
2439 assert_ne!(route.provider_id, "deepseek");
2440 }
2441
2442 #[test]
2443 fn cross_provider_profile_saves_reloads_and_resolves_to_its_own_provider() {
2444 // Required cross-provider save/load/launch coverage for #4093: create
2445 // a Fleet profile whose provider differs from the parent/session
2446 // provider, save it to a real TOML file, reload it from disk through
2447 // the same loader Fleet uses, then resolve its route and confirm the
2448 // resolved provider+model are the SAVED ones — never the parent's.
2449 let draft = crate::fleet::profile::FleetProfileDraft {
2450 id: "scout-openrouter".to_string(),
2451 display_name: Some("Scout".to_string()),
2452 description: Some("Cross-provider scout profile.".to_string()),
2453 role_hint: "scout".to_string(),
2454 model_class_hint: None,
2455 model: Some("deepseek-v4-flash".to_string()),
2456 provider: Some("openrouter".to_string()),
2457 reasoning_effort: Some("max".to_string()),
2458 instructions: None,
2459 };
2460
2461 let dir = tempfile::TempDir::new().unwrap();
2462 std::fs::write(dir.path().join(draft.file_name()), draft.render_toml()).unwrap();
2463 let profiles = crate::fleet::profile::load_agent_profiles_from_dir(dir.path())
2464 .expect("rendered profile TOML loads");
2465 assert_eq!(profiles.len(), 1);
2466 assert_eq!(profiles[0].profile.provider.as_deref(), Some("openrouter"));
2467 assert_eq!(profiles[0].profile.reasoning_effort.as_deref(), Some("max"));
2468 assert_eq!(
2469 profiles[0].profile.model.as_deref(),
2470 Some("deepseek-v4-flash")
2471 );
2472
2473 let task = fleet_task(
2474 "route-saved-profile",
2475 Some(worker_profile(
2476 Some("scout-openrouter"),
2477 None,
2478 None,
2479 None,
2480 None,
2481 vec![],
2482 )),
2483 );
2484
2485 // "Parent"/session route: a different provider's model entirely, so a
2486 // fallback to it would be an obvious, loud test failure.
2487 let route = resolve_fleet_route(&task, &profiles, Some("deepseek-v4-pro"))
2488 .expect("saved cross-provider profile route should resolve");
2489
2490 let openrouter_candidate = resolve_route_candidate(
2491 ApiProvider::Openrouter,
2492 Some("deepseek-v4-flash"),
2493 None,
2494 None,
2495 None,
2496 )
2497 .expect("openrouter should resolve the saved model directly");
2498 assert_eq!(
2499 route.wire_model_id,
2500 openrouter_candidate.wire_model_id().as_str()
2501 );
2502 assert_eq!(
2503 route.provider_id,
2504 openrouter_candidate.provider_id().as_str()
2505 );
2506 assert_eq!(route.reasoning_effort.as_deref(), Some("max"));
2507 assert_ne!(route.provider_id, "deepseek");
2508 }
2509
2510 #[test]
2511 fn resolve_fleet_route_preserves_exact_named_custom_provider_without_secrets() {
2512 let mut profile = agent_profile(
2513 "local",
2514 "scout",
2515 None,
2516 codewhale_config::FleetLoadout::Inherit,
2517 );
2518 profile.profile.model = Some("qwen-2.5-7b".to_string());
2519 profile.profile.provider = Some("lm-studio".to_string());
2520 let task = fleet_task(
2521 "custom-receipt",
2522 Some(worker_profile(
2523 Some("local"),
2524 None,
2525 None,
2526 None,
2527 None,
2528 vec![],
2529 )),
2530 );
2531
2532 assert!(
2533 resolve_fleet_route(&task, &[profile.clone()], Some("deepseek-v4-pro")).is_none(),
2534 "a profile string alone is not proof that a named custom route exists"
2535 );
2536 let config = Config {
2537 provider: Some("lm-studio".to_string()),
2538 providers: Some(crate::config::ProvidersConfig {
2539 custom: std::collections::HashMap::from([(
2540 "lm-studio".to_string(),
2541 crate::config::ProviderConfig {
2542 kind: Some("openai-compatible".to_string()),
2543 base_url: Some("http://127.0.0.1:1234/v1".to_string()),
2544 model: Some("qwen-2.5-7b".to_string()),
2545 api_key: Some("receipt-must-redact-this".to_string()),
2546 ..Default::default()
2547 },
2548 )]),
2549 ..Default::default()
2550 }),
2551 ..Default::default()
2552 };
2553 let route = resolve_fleet_route_with_config(
2554 &task,
2555 &[profile],
2556 Some("deepseek-v4-pro"),
2557 Some(&config),
2558 )
2559 .expect("live config should prove the named custom route");
2560
2561 assert_eq!(route.provider_id, "lm-studio");
2562 assert_eq!(route.provider_exact_id.as_deref(), Some("lm-studio"));
2563 assert_eq!(route.provider_kind, "custom");
2564 assert_eq!(route.wire_model_id, "qwen-2.5-7b");
2565 assert_eq!(route.protocol, "chat_completions");
2566 assert_eq!(route.model_source.as_deref(), Some("agent_profile.model"));
2567 assert_eq!(route.source, "runtime_route");
2568
2569 // The exact identity and wire model are durable, while endpoint/auth
2570 // config remains outside the receipt. The generic Custom descriptor's
2571 // placeholder endpoint is never serialized either.
2572 let json = serde_json::to_string(&route).unwrap();
2573 let haystack = json.to_ascii_lowercase();
2574 assert!(haystack.contains("lm-studio"));
2575 assert!(!haystack.contains("base_url"));
2576 assert!(!haystack.contains("http://"));
2577 assert!(!haystack.contains("https://"));
2578 for needle in [
2579 "api_key",
2580 "apikey",
2581 "api-key",
2582 "authorization",
2583 "bearer ",
2584 "auth_token",
2585 "auth-token",
2586 "password",
2587 "credential",
2588 "sk-ant-",
2589 "sk-proj-",
2590 "sk-or-",
2591 "secret",
2592 "receipt-must-redact-this",
2593 ] {
2594 assert!(
2595 !haystack.contains(needle),
2596 "named-custom route JSON must not contain secret marker {needle:?}: {json}"
2597 );
2598 }
2599 }
2600
2601 #[test]
2602 fn fleet_receipt_prefers_live_case_colliding_custom_identity() {
2603 let mut profile = agent_profile(
2604 "case-local",
2605 "scout",
2606 None,
2607 codewhale_config::FleetLoadout::Inherit,
2608 );
2609 profile.profile.model = Some("case-model".to_string());
2610 profile.profile.provider = Some("CUSTOM".to_string());
2611 let task = fleet_task(
2612 "case-custom-receipt",
2613 Some(worker_profile(
2614 Some("case-local"),
2615 None,
2616 None,
2617 None,
2618 None,
2619 vec![],
2620 )),
2621 );
2622 let config = Config {
2623 provider: Some("CUSTOM".to_string()),
2624 providers: Some(crate::config::ProvidersConfig {
2625 custom: std::collections::HashMap::from([(
2626 "CUSTOM".to_string(),
2627 crate::config::ProviderConfig {
2628 kind: Some("openai-compatible".to_string()),
2629 base_url: Some("http://127.0.0.1:5678/v1".to_string()),
2630 model: Some("case-model".to_string()),
2631 ..Default::default()
2632 },
2633 )]),
2634 ..Default::default()
2635 }),
2636 ..Default::default()
2637 };
2638
2639 let route = resolve_fleet_route_with_config(
2640 &task,
2641 &[profile],
2642 Some("deepseek-v4-pro"),
2643 Some(&config),
2644 )
2645 .expect("live config route proof");
2646 assert_eq!(route.provider_id, "CUSTOM");
2647 assert_eq!(route.provider_exact_id.as_deref(), Some("CUSTOM"));
2648 assert_eq!(route.provider_kind, "custom");
2649 assert_eq!(route.source, "runtime_route");
2650 }
2651
2652 #[test]
2653 fn worker_report_route_preserves_literal_custom_vs_idless_root_without_local_resolution() {
2654 let task = fleet_task("reported-custom", None);
2655 let literal = resolve_fleet_route_from_worker_report(
2656 &task,
2657 &[],
2658 Some("manager-model-y"),
2659 "custom",
2660 Some("custom"),
2661 "worker-model-x",
2662 )
2663 .expect("literal custom worker report");
2664 let root = resolve_fleet_route_from_worker_report(
2665 &task,
2666 &[],
2667 Some("manager-model-y"),
2668 "custom",
2669 None,
2670 "worker-model-root",
2671 )
2672 .expect("idless root custom worker report");
2673
2674 assert_eq!(literal.provider_id, "custom");
2675 assert_eq!(literal.provider_exact_id.as_deref(), Some("custom"));
2676 assert_eq!(literal.wire_model_id, "worker-model-x");
2677 assert_eq!(root.provider_id, "custom");
2678 assert_eq!(root.provider_exact_id, None);
2679 assert_eq!(root.wire_model_id, "worker-model-root");
2680 assert_eq!(literal.source, "worker_terminal_metadata");
2681 assert_eq!(root.source, "worker_terminal_metadata");
2682
2683 let literal_json = serde_json::to_value(&literal).unwrap();
2684 let root_json = serde_json::to_value(&root).unwrap();
2685 assert_eq!(literal_json["provider_exact_id"], "custom");
2686 assert!(root_json.get("provider_exact_id").is_none());
2687 assert_ne!(literal, root);
2688 }
2689
2690 #[test]
2691 fn worker_report_builtin_route_does_not_become_custom_exact_route() {
2692 let task = fleet_task("reported-built-in", None);
2693 let route = resolve_fleet_route_from_worker_report(
2694 &task,
2695 &[],
2696 None,
2697 "deepseek",
2698 None,
2699 "deepseek-v4-pro",
2700 )
2701 .expect("built-in worker report");
2702
2703 assert_eq!(route.provider_id, "deepseek");
2704 assert_eq!(route.provider_exact_id, None);
2705 assert_eq!(route.provider_kind, "deepseek");
2706
2707 assert!(
2708 resolve_fleet_route_from_worker_report(
2709 &task,
2710 &[],
2711 None,
2712 "deepseek",
2713 Some("custom-x"),
2714 "deepseek-v4-pro",
2715 )
2716 .is_none(),
2717 "built-in kind plus custom exact id is contradictory provenance"
2718 );
2719 assert!(
2720 resolve_fleet_route_from_worker_report(
2721 &task,
2722 &[],
2723 None,
2724 "custom",
2725 Some(" "),
2726 "root-model",
2727 )
2728 .is_none(),
2729 "present-empty exact id must not collapse to idless custom root"
2730 );
2731 }
2732
2733 #[test]
2734 fn fleet_worker_launch_route_is_explicit_provider_only() {
2735 // The LAUNCH resolver (twin of the receipt) must emit a provider ONLY
2736 // when the profile explicitly pins one, and NEVER infer it from a
2737 // provider-shaped model id (EPIC #2608).
2738
2739 // 1) Explicit cross-provider pin: model + provider both come from the
2740 // profile, not the parent/session model.
2741 let mut pinned = agent_profile(
2742 "cross",
2743 "scout",
2744 None,
2745 codewhale_config::FleetLoadout::Inherit,
2746 );
2747 pinned.profile.model = Some("glm-5.2".to_string());
2748 pinned.profile.provider = Some("openrouter".to_string());
2749 pinned.profile.reasoning_effort = Some("high".to_string());
2750 let pinned_task = fleet_task(
2751 "launch-pinned",
2752 Some(worker_profile(
2753 Some("cross"),
2754 None,
2755 None,
2756 None,
2757 None,
2758 vec![],
2759 )),
2760 );
2761 let pinned_profiles = vec![pinned];
2762 let (model, provider) =
2763 fleet_worker_launch_route(&pinned_task, &pinned_profiles, "deepseek-v4-pro");
2764 assert_eq!(model, "glm-5.2");
2765 assert_eq!(provider.as_deref(), Some("openrouter"));
2766 assert_eq!(
2767 fleet_worker_launch_reasoning_effort(&pinned_task, &pinned_profiles).as_deref(),
2768 Some("high")
2769 );
2770
2771 // 1b) User-named OpenAI-compatible providers are launchable too: keep
2772 // the exact provider id so `codewhale exec --provider lm-studio`
2773 // can resolve `[providers.lm-studio]` from config (#3965).
2774 let mut custom = agent_profile(
2775 "local",
2776 "scout",
2777 None,
2778 codewhale_config::FleetLoadout::Inherit,
2779 );
2780 custom.profile.model = Some("qwen-2.5-7b".to_string());
2781 custom.profile.provider = Some("lm-studio".to_string());
2782 let custom_task = fleet_task(
2783 "launch-custom",
2784 Some(worker_profile(
2785 Some("local"),
2786 None,
2787 None,
2788 None,
2789 None,
2790 vec![],
2791 )),
2792 );
2793 let custom_profiles = vec![custom];
2794 let (model, provider) =
2795 fleet_worker_launch_route(&custom_task, &custom_profiles, "deepseek-v4-pro");
2796 assert_eq!(model, "qwen-2.5-7b");
2797 assert_eq!(provider.as_deref(), Some("lm-studio"));
2798
2799 // 2) A DeepSeek-shaped model with NO explicit provider must NOT infer a
2800 // provider — provider stays None so the worker keeps its own session
2801 // default, and no `--provider` is emitted.
2802 let mut model_only = agent_profile(
2803 "modelonly",
2804 "scout",
2805 None,
2806 codewhale_config::FleetLoadout::Inherit,
2807 );
2808 model_only.profile.model = Some("deepseek-v4-flash".to_string());
2809 let model_only_task = fleet_task(
2810 "launch-model-only",
2811 Some(worker_profile(
2812 Some("modelonly"),
2813 None,
2814 None,
2815 None,
2816 None,
2817 vec![],
2818 )),
2819 );
2820 let model_only_profiles = vec![model_only];
2821 let (model, provider) =
2822 fleet_worker_launch_route(&model_only_task, &model_only_profiles, "deepseek-v4-pro");
2823 assert_eq!(model, "deepseek-v4-flash");
2824 assert_eq!(provider, None);
2825 assert_eq!(
2826 fleet_worker_launch_reasoning_effort(&model_only_task, &model_only_profiles),
2827 None
2828 );
2829
2830 // 3) No profile at all: run-level model, no provider (unchanged).
2831 let bare = fleet_task("launch-bare", None);
2832 let (model, provider) = fleet_worker_launch_route(&bare, &[], "deepseek-v4-pro");
2833 assert_eq!(model, "deepseek-v4-pro");
2834 assert_eq!(provider, None);
2835 }
2836
2837 #[test]
2838 fn fleet_worker_spec_rejects_unknown_agent_profile_before_spawn() {
2839 let task = fleet_task(
2840 "review",
2841 Some(worker_profile(
2842 Some("missing"),
2843 None,
2844 None,
2845 None,
2846 None,
2847 vec![],
2848 )),
2849 );
2850
2851 let err = validate_task_agent_profiles(&[task], &[])
2852 .expect_err("unknown agent profile must fail validation");
2853
2854 assert!(
2855 err.to_string()
2856 .contains("references unknown agent profile \"missing\"")
2857 );
2858 }
2859
2860 #[test]
2861 fn fleet_worker_spec_uses_profile_model_and_task_model_precedence() {
2862 let mut profile = agent_profile(
2863 "reviewer",
2864 "reviewer",
2865 Some("Focus on regressions and missing tests."),
2866 codewhale_config::FleetLoadout::Inherit,
2867 );
2868 profile.profile.model = Some("glm-5.2".to_string());
2869 let worker = FleetWorkerSpec {
2870 id: "worker-1".to_string(),
2871 name: "Worker".to_string(),
2872 host: FleetHostSpec::Local,
2873 trust_level: None,
2874 labels: Default::default(),
2875 capabilities: vec![],
2876 max_concurrent_tasks: None,
2877 };
2878
2879 let profile_model_spec = fleet_task_to_worker_spec_with_profiles(
2880 "worker-1",
2881 "run-1",
2882 &fleet_task(
2883 "review",
2884 Some(worker_profile(
2885 Some("reviewer"),
2886 None,
2887 None,
2888 None,
2889 None,
2890 vec![],
2891 )),
2892 ),
2893 &worker,
2894 "auto",
2895 std::path::Path::new("/tmp"),
2896 std::path::Path::new("/tmp"),
2897 &[profile.clone()],
2898 None,
2899 )
2900 .unwrap();
2901
2902 assert_eq!(profile_model_spec.model, "glm-5.2");
2903 assert_eq!(
2904 profile_model_spec.runtime_profile.model,
2905 ModelRoute::Fixed("glm-5.2".to_string())
2906 );
2907
2908 let task_model_spec = fleet_task_to_worker_spec_with_profiles(
2909 "worker-2",
2910 "run-1",
2911 &fleet_task(
2912 "review",
2913 Some(worker_profile(
2914 Some("reviewer"),
2915 None,
2916 None,
2917 None,
2918 Some("deepseek-v4-pro"),
2919 vec![],
2920 )),
2921 ),
2922 &worker,
2923 "auto",
2924 std::path::Path::new("/tmp"),
2925 std::path::Path::new("/tmp"),
2926 &[profile],
2927 None,
2928 )
2929 .unwrap();
2930
2931 assert_eq!(task_model_spec.model, "deepseek-v4-pro");
2932 assert_eq!(
2933 task_model_spec.runtime_profile.model,
2934 ModelRoute::Fixed("deepseek-v4-pro".to_string())
2935 );
2936 }
2937
2938 #[test]
2939 fn fleet_worker_spec_carries_agent_profile_provider_through_runtime_contract() {
2940 let mut profile = agent_profile(
2941 "scout-openrouter",
2942 "scout",
2943 Some("Use the OpenRouter scout route."),
2944 codewhale_config::FleetLoadout::Fast,
2945 );
2946 profile.profile.model = Some("deepseek-v4-flash".to_string());
2947 profile.profile.provider = Some("openrouter".to_string());
2948 profile.profile.reasoning_effort = Some("max".to_string());
2949 let task = fleet_task(
2950 "scout",
2951 Some(worker_profile(
2952 Some("scout-openrouter"),
2953 None,
2954 None,
2955 None,
2956 None,
2957 vec![],
2958 )),
2959 );
2960 let worker = FleetWorkerSpec {
2961 id: "worker-1".to_string(),
2962 name: "Worker".to_string(),
2963 host: FleetHostSpec::Local,
2964 trust_level: None,
2965 labels: Default::default(),
2966 capabilities: vec![],
2967 max_concurrent_tasks: None,
2968 };
2969 let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker);
2970 parent.provider = Some("deepseek".to_string());
2971 parent.reasoning_effort = Some("low".to_string());
2972 parent.max_spawn_depth = 3;
2973
2974 let spec = fleet_task_to_worker_spec_with_profiles(
2975 "worker-1",
2976 "run-1",
2977 &task,
2978 &worker,
2979 "deepseek-v4-pro",
2980 std::path::Path::new("/tmp"),
2981 std::path::Path::new("/tmp"),
2982 &[profile],
2983 Some(&parent),
2984 )
2985 .unwrap();
2986
2987 assert_eq!(spec.model, "deepseek-v4-flash");
2988 assert_eq!(
2989 spec.runtime_profile.model,
2990 ModelRoute::Fixed("deepseek-v4-flash".to_string())
2991 );
2992 assert_eq!(spec.runtime_profile.provider.as_deref(), Some("openrouter"));
2993 assert_eq!(
2994 spec.runtime_profile.reasoning_effort.as_deref(),
2995 Some("max")
2996 );
2997 assert_eq!(spec.runtime_profile.max_spawn_depth, 2);
2998 }
2999
3000 #[test]
3001 fn fleet_worker_spec_model_route_precedence_is_task_profile_role_then_session() {
3002 let worker = FleetWorkerSpec {
3003 id: "worker-1".to_string(),
3004 name: "Worker".to_string(),
3005 host: FleetHostSpec::Local,
3006 trust_level: None,
3007 labels: Default::default(),
3008 capabilities: vec![],
3009 max_concurrent_tasks: None,
3010 };
3011 let run_model = "deepseek-v4-pro";
3012
3013 let mut profile =
3014 agent_profile("scout", "scout", None, codewhale_config::FleetLoadout::Fast);
3015 profile.profile.model = Some("deepseek-v4-flash".to_string());
3016
3017 let task_model = fleet_task_to_worker_spec_with_profiles(
3018 "worker-task",
3019 "run-1",
3020 &fleet_task(
3021 "task-model",
3022 Some(worker_profile(
3023 Some("scout"),
3024 None,
3025 None,
3026 None,
3027 Some("deepseek-v4.1"),
3028 vec![],
3029 )),
3030 ),
3031 &worker,
3032 run_model,
3033 std::path::Path::new("/tmp"),
3034 std::path::Path::new("/tmp"),
3035 &[profile.clone()],
3036 None,
3037 )
3038 .unwrap();
3039 assert_eq!(task_model.model, "deepseek-v4.1");
3040 assert_eq!(
3041 task_model.runtime_profile.model,
3042 ModelRoute::Fixed("deepseek-v4.1".to_string())
3043 );
3044
3045 let profile_model = fleet_task_to_worker_spec_with_profiles(
3046 "worker-profile",
3047 "run-1",
3048 &fleet_task(
3049 "profile-model",
3050 Some(worker_profile(
3051 Some("scout"),
3052 None,
3053 None,
3054 None,
3055 None,
3056 vec![],
3057 )),
3058 ),
3059 &worker,
3060 run_model,
3061 std::path::Path::new("/tmp"),
3062 std::path::Path::new("/tmp"),
3063 &[profile],
3064 None,
3065 )
3066 .unwrap();
3067 assert_eq!(profile_model.model, "deepseek-v4-flash");
3068 assert_eq!(
3069 profile_model.runtime_profile.model,
3070 ModelRoute::Fixed("deepseek-v4-flash".to_string())
3071 );
3072
3073 let role_default = fleet_task_to_worker_spec_with_profiles(
3074 "worker-role",
3075 "run-1",
3076 &fleet_task(
3077 "role-default",
3078 Some(worker_profile(
3079 None,
3080 Some("scout"),
3081 Some("fast"),
3082 None,
3083 None,
3084 vec![],
3085 )),
3086 ),
3087 &worker,
3088 run_model,
3089 std::path::Path::new("/tmp"),
3090 std::path::Path::new("/tmp"),
3091 &[],
3092 None,
3093 )
3094 .unwrap();
3095 assert_eq!(role_default.model, run_model);
3096 assert_eq!(role_default.runtime_profile.model, ModelRoute::Faster);
3097
3098 let inherited = fleet_task_to_worker_spec_with_profiles(
3099 "worker-inherit",
3100 "run-1",
3101 &fleet_task("inherit", None),
3102 &worker,
3103 run_model,
3104 std::path::Path::new("/tmp"),
3105 std::path::Path::new("/tmp"),
3106 &[],
3107 None,
3108 )
3109 .unwrap();
3110 assert_eq!(inherited.model, run_model);
3111 assert_eq!(inherited.runtime_profile.model, ModelRoute::Inherit);
3112 }
3113
3114 #[test]
3115 fn fleet_worker_spec_intersects_task_tools_with_parent_runtime_profile() {
3116 let task = fleet_task(
3117 "build",
3118 Some(worker_profile(
3119 None,
3120 Some("builder"),
3121 None,
3122 Some("fast"),
3123 None,
3124 vec!["read_file", "apply_patch"],
3125 )),
3126 );
3127 let worker = FleetWorkerSpec {
3128 id: "worker-1".to_string(),
3129 name: "Worker".to_string(),
3130 host: FleetHostSpec::Local,
3131 trust_level: None,
3132 labels: Default::default(),
3133 capabilities: vec![],
3134 max_concurrent_tasks: None,
3135 };
3136 let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Scout);
3137 parent.tools = ToolScope::Explicit(vec!["read_file".to_string()]);
3138 parent.max_spawn_depth = 2;
3139
3140 let spec = fleet_task_to_worker_spec_with_profiles(
3141 "worker-1",
3142 "run-1",
3143 &task,
3144 &worker,
3145 "auto",
3146 std::path::Path::new("/tmp"),
3147 std::path::Path::new("/tmp"),
3148 &[],
3149 Some(&parent),
3150 )
3151 .unwrap();
3152
3153 assert_eq!(spec.agent_type, FleetRole::Builder);
3154 assert!(!spec.runtime_profile.permissions.write);
3155 assert!(
3156 spec.runtime_profile.permissions.network,
3157 "recon lanes keep network reach"
3158 );
3159 assert_eq!(
3160 spec.runtime_profile.shell,
3161 crate::worker_profile::ShellPolicy::Full
3162 );
3163 assert_eq!(
3164 spec.runtime_profile.tools,
3165 ToolScope::Explicit(vec!["read_file".to_string()])
3166 );
3167 assert_eq!(spec.runtime_profile.model, ModelRoute::Faster);
3168 assert_eq!(spec.max_spawn_depth, 1);
3169
3170 let permissions = fleet_effective_permissions_from_worker_spec(&spec);
3171 assert!(!permissions.write);
3172 assert!(permissions.network, "recon lanes keep network reach");
3173 assert_eq!(permissions.shell, "full");
3174 assert_eq!(permissions.tool_scope, "explicit");
3175 assert_eq!(permissions.tools, vec!["read_file".to_string()]);
3176 assert!(permissions.background);
3177 assert_eq!(permissions.max_spawn_depth, 1);
3178 assert_eq!(permissions.source, "worker_runtime_profile");
3179 }
3180
3181 #[test]
3182 fn fleet_worker_spec_defaults_to_shared_subagent_depth() {
3183 let task = FleetTaskSpec {
3184 id: "task-1".to_string(),
3185 name: "Task".to_string(),
3186 description: None,
3187 objective: None,
3188 instructions: "Do the task.".to_string(),
3189 worker: Some(FleetTaskWorkerProfile {
3190 agent_profile: None,
3191 role: Some("reviewer".to_string()),
3192 loadout: None,
3193 model_class: None,
3194 model: None,
3195 tool_profile: Some("read-only".to_string()),
3196 tools: Vec::new(),
3197 capabilities: Vec::new(),
3198 }),
3199 workspace: None,
3200 input_files: vec![],
3201 context: vec![],
3202 budget: None,
3203 tags: vec![],
3204 expected_artifacts: vec![],
3205 scorer: None,
3206 retry_policy: None,
3207 alert_policy: None,
3208 timeout_seconds: None,
3209 metadata: Default::default(),
3210 };
3211 let worker = FleetWorkerSpec {
3212 id: "worker-1".to_string(),
3213 name: "Worker".to_string(),
3214 host: FleetHostSpec::Local,
3215 trust_level: None,
3216 labels: Default::default(),
3217 capabilities: vec![],
3218 max_concurrent_tasks: None,
3219 };
3220
3221 let spec = fleet_task_to_worker_spec_with_profiles(
3222 "worker-1",
3223 "run-1",
3224 &task,
3225 &worker,
3226 "auto",
3227 std::path::Path::new("/tmp"),
3228 std::path::Path::new("/tmp"),
3229 &[],
3230 None,
3231 )
3232 .expect("worker spec with empty profiles");
3233
3234 // Root fleet worker runs at depth 0; its budget equals the shared
3235 // sub-agent default (3) so fleet and sub-agents are one substrate and
3236 // at least 3 nested delegation levels are afforded.
3237 assert_eq!(spec.spawn_depth, 0);
3238 assert_eq!(spec.max_spawn_depth, codewhale_config::DEFAULT_SPAWN_DEPTH);
3239 assert_eq!(spec.max_spawn_depth, 3);
3240
3241 // End-to-end reachability: walk the SAME gate the SubAgentRuntime
3242 // enforces (`would_exceed_depth` = `spawn_depth + 1 > max_spawn_depth`).
3243 // A depth-0 root must reach 3 nested levels, then stop. This fails if
3244 // anyone lowers the shared default below 3 (Hunter: afford >= 3).
3245 let hardened = apply_exec_hardening(spec, &codewhale_config::FleetExecConfig::default());
3246 let would_exceed = |spawn_depth: u32| spawn_depth + 1 > hardened.max_spawn_depth;
3247 assert!(
3248 !would_exceed(0),
3249 "root (depth 0) must spawn a child at depth 1"
3250 );
3251 assert!(!would_exceed(1), "depth-1 child must spawn to depth 2");
3252 assert!(!would_exceed(2), "depth-2 child must spawn to depth 3");
3253 assert!(
3254 would_exceed(3),
3255 "depth 3 is the afforded ceiling; depth 4 is blocked"
3256 );
3257 }
3258
3259 #[test]
3260 fn fleet_fanout_role_loadouts_keep_distinct_child_models() {
3261 let worker = FleetWorkerSpec {
3262 id: "local-worker".to_string(),
3263 name: "Local worker".to_string(),
3264 host: FleetHostSpec::Local,
3265 trust_level: None,
3266 labels: Default::default(),
3267 capabilities: vec![],
3268 max_concurrent_tasks: None,
3269 };
3270
3271 let cases = [
3272 (
3273 "scout",
3274 "deepseek-v4-flash",
3275 FleetRole::Scout,
3276 AgentWorkerToolProfile::Explicit(vec![
3277 "read_file".to_string(),
3278 "grep_files".to_string(),
3279 ]),
3280 ),
3281 (
3282 "builder",
3283 "deepseek-v4-pro",
3284 FleetRole::Builder,
3285 AgentWorkerToolProfile::Explicit(vec![
3286 "read_file".to_string(),
3287 "apply_patch".to_string(),
3288 ]),
3289 ),
3290 (
3291 "verifier",
3292 "deepseek-v4-pro",
3293 FleetRole::Verifier,
3294 AgentWorkerToolProfile::Explicit(vec![
3295 "exec_shell".to_string(),
3296 "read_file".to_string(),
3297 ]),
3298 ),
3299 ];
3300
3301 let parent_model = "parent-session-model";
3302 let mut child_models = std::collections::BTreeSet::new();
3303 for (role, model, expected_type, expected_tools) in cases {
3304 let task = FleetTaskSpec {
3305 id: format!("{role}-task"),
3306 name: format!("{role} task"),
3307 description: None,
3308 objective: Some(format!("{role} objective")),
3309 instructions: "Complete the assigned fanout lane.".to_string(),
3310 worker: Some(FleetTaskWorkerProfile {
3311 agent_profile: None,
3312 role: Some(role.to_string()),
3313 loadout: None,
3314 model_class: None,
3315 model: None,
3316 tool_profile: None,
3317 tools: match &expected_tools {
3318 AgentWorkerToolProfile::Explicit(tools) => tools.clone(),
3319 AgentWorkerToolProfile::Inherited => Vec::new(),
3320 },
3321 capabilities: vec![],
3322 }),
3323 workspace: matches!(&expected_type, FleetRole::Builder).then(|| {
3324 FleetWorkspaceRequirements {
3325 root: Some(PathBuf::from(".")),
3326 required_files: Vec::new(),
3327 writable_paths: vec![PathBuf::from(".")],
3328 environment: None,
3329 }
3330 }),
3331 input_files: vec![],
3332 context: vec![],
3333 budget: None,
3334 tags: vec![],
3335 expected_artifacts: vec![],
3336 scorer: None,
3337 retry_policy: None,
3338 alert_policy: None,
3339 timeout_seconds: None,
3340 metadata: Default::default(),
3341 };
3342
3343 let spec = fleet_task_to_worker_spec_with_profiles(
3344 &format!("{role}-worker"),
3345 "run-3289",
3346 &task,
3347 &worker,
3348 model,
3349 std::path::Path::new("/tmp"),
3350 std::path::Path::new("/tmp"),
3351 &[],
3352 None,
3353 )
3354 .expect("worker spec with empty profiles");
3355
3356 assert_eq!(spec.role.as_deref(), Some(role));
3357 assert_eq!(spec.agent_type, expected_type, "role {role}");
3358 assert_eq!(spec.tool_profile, expected_tools, "role {role}");
3359 assert_eq!(spec.model, model, "role {role}");
3360 assert_ne!(
3361 spec.model, parent_model,
3362 "Fleet fanout child {role} must use its resolved loadout, not blindly inherit"
3363 );
3364 assert_eq!(
3365 spec.runtime_profile.model,
3366 ModelRoute::Inherit,
3367 "role {role}"
3368 );
3369 assert_eq!(spec.runtime_profile.role, expected_type, "role {role}");
3370 child_models.insert(spec.model.clone());
3371 }
3372 assert_eq!(
3373 child_models,
3374 std::collections::BTreeSet::from([
3375 "deepseek-v4-flash".to_string(),
3376 "deepseek-v4-pro".to_string(),
3377 ]),
3378 "Fleet fanout should preserve a mixed scout/builder/verifier loadout"
3379 );
3380 }
3381
3382 #[test]
3383 fn fleet_route_parity_uses_shared_router_candidates() {
3384 use crate::config::ApiProvider;
3385 use crate::model_routing::{RouterCandidates, provider_router_candidates};
3386
3387 // Fleet emits the SAME `ModelRoute` seam the sub-agent assignment path
3388 // consumes (`SubAgentModelStrength::model_route`: fast -> Faster,
3389 // same/inherit -> Inherit). No fleet-specific provider/model table is
3390 // involved — only the shared enum.
3391 assert_eq!(
3392 fleet_model_route_for_loadout("auto", &codewhale_config::FleetLoadout::Fast),
3393 ModelRoute::Faster,
3394 );
3395 assert_eq!(
3396 fleet_model_route_for_loadout("auto", &codewhale_config::FleetLoadout::Inherit),
3397 ModelRoute::Inherit,
3398 );
3399 assert_eq!(
3400 fleet_model_route_for_loadout(
3401 "auto",
3402 &codewhale_config::FleetLoadout::Custom("strong".to_string())
3403 ),
3404 ModelRoute::Auto,
3405 );
3406 // An explicit model always pins to a Fixed route, regardless of loadout.
3407 assert_eq!(
3408 fleet_model_route_for_loadout(
3409 "deepseek-v4-flash",
3410 &codewhale_config::FleetLoadout::Custom("strong".to_string())
3411 ),
3412 ModelRoute::Fixed("deepseek-v4-flash".to_string()),
3413 );
3414
3415 // The sub-agent runtime resolves a `ModelRoute` to a concrete model via
3416 // `provider_router_candidates` (see `worker_profile_subagent_assignment_route`):
3417 // Fixed(m) -> m
3418 // Faster | Auto -> candidates.cheap (else parent)
3419 // Inherit -> parent
3420 // A fleet worker hands its `ModelRoute` to that same resolution, so a
3421 // fleet "fast" loadout lands on the provider's cheap sibling.
3422 let parent = "deepseek-v4-pro";
3423 let resolve = |route: &ModelRoute, candidates: &RouterCandidates| match route {
3424 ModelRoute::Fixed(model) => model.clone(),
3425 ModelRoute::Faster | ModelRoute::Auto => candidates
3426 .cheap
3427 .clone()
3428 .unwrap_or_else(|| parent.to_string()),
3429 ModelRoute::Inherit => parent.to_string(),
3430 };
3431
3432 let deepseek = provider_router_candidates(ApiProvider::Deepseek, parent);
3433 assert_eq!(
3434 resolve(
3435 &fleet_model_route_for_loadout("auto", &codewhale_config::FleetLoadout::Fast),
3436 &deepseek,
3437 ),
3438 "deepseek-v4-flash",
3439 "fleet fast loadout resolves to the provider cheap sibling via the shared router",
3440 );
3441
3442 // A provider with no known fast sibling must keep children on the parent
3443 // model rather than fabricating a cloud id (#3166 route assertion).
3444 let no_sibling = provider_router_candidates(ApiProvider::Anthropic, parent);
3445 assert_eq!(no_sibling.cheap, None);
3446 assert_eq!(
3447 resolve(
3448 &fleet_model_route_for_loadout("auto", &codewhale_config::FleetLoadout::Fast),
3449 &no_sibling,
3450 ),
3451 parent,
3452 "fast with no provider sibling stays on the parent/default model",
3453 );
3454 }
3455
3456 #[test]
3457 fn exec_hardening_caps_max_steps_to_max_turns() {
3458 let spec = AgentWorkerSpec {
3459 worker_id: "w1".to_string(),
3460 run_id: "r1".to_string(),
3461 parent_run_id: None,
3462 session_name: None,
3463 objective: "test".to_string(),
3464 role: None,
3465 agent_type: FleetRole::Worker,
3466 model: "auto".to_string(),
3467 workspace: std::path::PathBuf::from("/tmp"),
3468 git_branch: None,
3469 context_mode: "fresh".to_string(),
3470 fork_context: false,
3471 tool_profile: AgentWorkerToolProfile::Inherited,
3472 runtime_profile: WorkerRuntimeProfile::for_role(FleetRole::Worker),
3473 max_steps: 1000,
3474 spawn_depth: 0,
3475 max_spawn_depth: 0,
3476 launch_manifest: None,
3477 };
3478 let exec = codewhale_config::FleetExecConfig {
3479 max_turns: 50,
3480 ..Default::default()
3481 };
3482 let hardened = apply_exec_hardening(spec, &exec);
3483 assert_eq!(hardened.max_steps, 50);
3484 }
3485
3486 #[test]
3487 fn exec_hardening_applies_and_clamps_spawn_depth() {
3488 let spec = AgentWorkerSpec {
3489 worker_id: "w1".to_string(),
3490 run_id: "r1".to_string(),
3491 parent_run_id: None,
3492 session_name: None,
3493 objective: "test".to_string(),
3494 role: None,
3495 agent_type: FleetRole::Worker,
3496 model: "auto".to_string(),
3497 workspace: std::path::PathBuf::from("/tmp"),
3498 git_branch: None,
3499 context_mode: "fresh".to_string(),
3500 fork_context: false,
3501 tool_profile: AgentWorkerToolProfile::Inherited,
3502 runtime_profile: WorkerRuntimeProfile::for_role(FleetRole::Worker),
3503 max_steps: 1000,
3504 spawn_depth: 0,
3505 max_spawn_depth: 0,
3506 launch_manifest: None,
3507 };
3508
3509 let exec = codewhale_config::FleetExecConfig {
3510 max_spawn_depth: 2,
3511 ..Default::default()
3512 };
3513 let hardened = apply_exec_hardening(spec.clone(), &exec);
3514 assert_eq!(hardened.max_spawn_depth, 2);
3515
3516 let exec = codewhale_config::FleetExecConfig {
3517 max_spawn_depth: 99,
3518 ..Default::default()
3519 };
3520 let hardened = apply_exec_hardening(spec.clone(), &exec);
3521 assert_eq!(
3522 hardened.max_spawn_depth,
3523 codewhale_config::MAX_SPAWN_DEPTH_CEILING
3524 );
3525
3526 let exec = codewhale_config::FleetExecConfig {
3527 max_spawn_depth: 0,
3528 ..Default::default()
3529 };
3530 let hardened = apply_exec_hardening(spec, &exec);
3531 assert_eq!(hardened.max_spawn_depth, 0);
3532 }
3533
3534 #[test]
3535 fn exec_hardening_filters_disallowed_tools() {
3536 let profile = AgentWorkerToolProfile::Explicit(vec![
3537 "read_file".to_string(),
3538 "exec_shell".to_string(),
3539 "git_diff".to_string(),
3540 ]);
3541 let exec = codewhale_config::FleetExecConfig {
3542 disallowed_tools: vec!["exec_shell".to_string()],
3543 ..Default::default()
3544 };
3545 let filtered = filter_tool_profile(&profile, &exec);
3546 assert_eq!(
3547 filtered,
3548 AgentWorkerToolProfile::Explicit(
3549 vec!["read_file".to_string(), "git_diff".to_string(),]
3550 )
3551 );
3552 }
3553
3554 #[test]
3555 fn exec_hardening_allowed_tools_acts_as_allowlist() {
3556 let profile = AgentWorkerToolProfile::Explicit(vec![
3557 "read_file".to_string(),
3558 "exec_shell".to_string(),
3559 "git_diff".to_string(),
3560 ]);
3561 let exec = codewhale_config::FleetExecConfig {
3562 allowed_tools: vec!["read_file".to_string(), "git_diff".to_string()],
3563 ..Default::default()
3564 };
3565 let filtered = filter_tool_profile(&profile, &exec);
3566 assert_eq!(
3567 filtered,
3568 AgentWorkerToolProfile::Explicit(
3569 vec!["read_file".to_string(), "git_diff".to_string(),]
3570 )
3571 );
3572 }
3573
3574 #[test]
3575 fn exec_hardening_allowed_plus_disallowed_disallowed_wins() {
3576 let profile = AgentWorkerToolProfile::Explicit(vec![
3577 "read_file".to_string(),
3578 "exec_shell".to_string(),
3579 ]);
3580 let exec = codewhale_config::FleetExecConfig {
3581 allowed_tools: vec!["read_file".to_string(), "exec_shell".to_string()],
3582 disallowed_tools: vec!["exec_shell".to_string()],
3583 ..Default::default()
3584 };
3585 let filtered = filter_tool_profile(&profile, &exec);
3586 assert_eq!(
3587 filtered,
3588 AgentWorkerToolProfile::Explicit(vec!["read_file".to_string(),])
3589 );
3590 }
3591
3592 #[test]
3593 fn exec_hardening_appends_system_prompt() {
3594 let spec = AgentWorkerSpec {
3595 worker_id: "w1".to_string(),
3596 run_id: "r1".to_string(),
3597 parent_run_id: None,
3598 session_name: None,
3599 objective: "do the thing".to_string(),
3600 role: None,
3601 agent_type: FleetRole::Worker,
3602 model: "auto".to_string(),
3603 workspace: std::path::PathBuf::from("/tmp"),
3604 git_branch: None,
3605 context_mode: "fresh".to_string(),
3606 fork_context: false,
3607 tool_profile: AgentWorkerToolProfile::Inherited,
3608 runtime_profile: WorkerRuntimeProfile::for_role(FleetRole::Worker),
3609 max_steps: 100,
3610 spawn_depth: 0,
3611 max_spawn_depth: 0,
3612 launch_manifest: None,
3613 };
3614 let exec = codewhale_config::FleetExecConfig {
3615 append_system_prompt: "never push to main".to_string(),
3616 ..Default::default()
3617 };
3618 let hardened = apply_exec_hardening(spec, &exec);
3619 assert!(hardened.objective.contains("do the thing"));
3620 assert!(hardened.objective.contains("[Policy]"));
3621 assert!(hardened.objective.contains("never push to main"));
3622 }
3623 }
3624
3624 lines RUST