Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 17 additions & 7 deletions crates/hi-agent/src/agent/background_task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,14 @@ impl crate::Agent {
let summary: String = description.chars().take(72).collect();
ui.subagent_note(&format!("↳ background {subagent_type} task {n}: {summary}"));

// Build the future factory and spawn the task.
let provider = self.provider.clone();
// Build the future factory and spawn the task. Each role runs on its
// configured route (team roles): explore/delegate children may use a
// different model or endpoint than the driver.
let provider = if is_explore {
self.explore_child_provider()
} else {
self.delegate_child_provider()
};
let child_config = if is_explore {
self.build_bg_explore_config(n)
} else {
Expand Down Expand Up @@ -355,10 +361,7 @@ impl crate::Agent {

/// Build a child config for a background explore subagent.
fn build_bg_explore_config(&self, n: u32) -> AgentConfig {
let explore_model = std::env::var("HI_EXPLORE_MODEL")
.ok()
.filter(|model| !model.trim().is_empty())
.unwrap_or_else(|| self.config.routing.model.clone());
let explore_model = crate::agent::explore_turn::explore_child_model(&self.config);
AgentConfig {
paths: crate::AgentPaths {
workspace_root: self.runtime.root().to_path_buf(),
Expand Down Expand Up @@ -405,6 +408,13 @@ impl crate::Agent {

/// Build a child config for a background delegate subagent.
fn build_bg_delegate_config(&self, n: u32) -> AgentConfig {
let delegate_model = self
.config
.subagents
.delegate_model
.clone()
.filter(|model| !model.trim().is_empty())
.unwrap_or_else(|| self.config.routing.model.clone());
AgentConfig {
paths: crate::AgentPaths {
workspace_root: self.runtime.root().to_path_buf(),
Expand All @@ -415,7 +425,7 @@ impl crate::Agent {
.join(format!("bg-delegate-{n}")),
},
routing: crate::AgentRouting {
model: self.config.routing.model.clone(),
model: delegate_model,
requested_max_tokens: self.config.routing.requested_max_tokens,
max_tokens: self.config.routing.max_tokens,
max_tokens_explicit: self.config.routing.max_tokens_explicit,
Expand Down
17 changes: 16 additions & 1 deletion crates/hi-agent/src/agent/delegate_turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ pub(crate) struct DelegateJob {
pub(crate) task: String,
pub(crate) verify: Option<String>,
pub(crate) runner: std::sync::Arc<dyn crate::DelegateRunner>,
/// Team-role route override for this executor (all-`None` = driver route).
pub(crate) route: crate::SubagentRoute,
pub(crate) cancellation: crate::TurnCancellation,
/// File paths extracted from the task description (best-effort). Used to
/// detect overlap between parallel delegates — only disjoint file sets
Expand Down Expand Up @@ -242,13 +244,24 @@ impl crate::Agent {
task,
verify,
runner,
route: self.delegate_route(),
cancellation: crate::TurnCancellation::new(),
file_set,
},
ledger_revision,
))
}

/// The configured executor route for `delegate` children (team roles).
/// All-`None` inherits the driver's provider/model.
pub(crate) fn delegate_route(&self) -> crate::SubagentRoute {
crate::SubagentRoute {
model: self.config.subagents.delegate_model.clone(),
base_url: self.config.subagents.delegate_endpoint.clone(),
api_key: self.config.subagents.delegate_endpoint_key.clone(),
}
}

/// Run one write-capable `delegate` subagent and return a summary. The runner
/// isolates it in a worktree and applies its changes back only if verification
/// passes; on failure nothing touches the real tree (spatial isolation).
Expand Down Expand Up @@ -487,11 +500,12 @@ pub(crate) async fn run_delegate_job(job: DelegateJob) -> DelegateJobResult {
task,
verify,
runner,
route,
cancellation,
file_set: _,
} = job;
let outcome = runner
.run_cancellable(&task, verify.as_deref(), cancellation)
.run_routed(&task, verify.as_deref(), &route, cancellation)
.await;
DelegateJobResult { slot, outcome }
}
Expand Down Expand Up @@ -613,6 +627,7 @@ mod tests {
task: format!("update src/module-{slot}.rs"),
verify: None,
runner: runner_for_job,
route: crate::SubagentRoute::default(),
cancellation: crate::TurnCancellation::new(),
file_set: std::collections::BTreeSet::from([format!("src/module-{slot}.rs")]),
})));
Expand Down
104 changes: 99 additions & 5 deletions crates/hi-agent/src/agent/explore_turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,7 @@ impl crate::Agent {
let n = self
.subagents
.try_begin_explore(MAX_EXPLORE_SUBAGENTS_PER_TURN)?;
let child_model = std::env::var("HI_EXPLORE_MODEL")
.ok()
.filter(|model| !model.trim().is_empty())
.unwrap_or_else(|| self.config.routing.model.clone());
let child_model = explore_child_model(&self.config);
let child_project_context = self
.config
.memory
Expand Down Expand Up @@ -150,11 +147,35 @@ impl crate::Agent {
Some(ExploreJob {
slot: n,
task,
provider: self.provider.clone(),
provider: self.explore_child_provider(),
child_config,
})
}

/// The provider explore children run on. Shares the driver's connection
/// unless an `explore_endpoint` is configured (team roles), in which case
/// recon runs on its own OpenAI-compatible route — typically a local
/// model, so read-heavy fan-out costs nothing.
pub(crate) fn explore_child_provider(&self) -> std::sync::Arc<dyn hi_ai::Provider> {
routed_provider(
self.config.subagents.explore_endpoint.as_deref(),
self.config.subagents.explore_endpoint_key.as_deref(),
&self.provider,
)
}

/// The provider in-process background `delegate` tasks run on — the
/// delegate route when configured, else the driver's provider. (The
/// synchronous delegate path applies the same route in its child-process
/// runner instead.)
pub(crate) fn delegate_child_provider(&self) -> std::sync::Arc<dyn hi_ai::Provider> {
routed_provider(
self.config.subagents.delegate_endpoint.as_deref(),
self.config.subagents.delegate_endpoint_key.as_deref(),
&self.provider,
)
}

/// Run one read-only `explore` subagent for the `{task}` argument and return
/// its answer as the tool result. Best-effort: a provider/parse error becomes
/// an error string fed back to the model, never fatal to the parent turn.
Expand Down Expand Up @@ -360,10 +381,83 @@ impl Ui for BufferingUi {
fn turn_end(&mut self, _summary: &str) {}
}

/// The model explore children run: `HI_EXPLORE_MODEL` env (highest, a live
/// escape hatch) → `subagents.explore_model` (team roles) → the driver model.
pub(crate) fn explore_child_model(config: &crate::AgentConfig) -> String {
std::env::var("HI_EXPLORE_MODEL")
.ok()
.filter(|model| !model.trim().is_empty())
.or_else(|| {
config
.subagents
.explore_model
.clone()
.filter(|model| !model.trim().is_empty())
})
.unwrap_or_else(|| config.routing.model.clone())
}

/// Build the provider for a routed subagent role: a dedicated
/// OpenAI-compatible client when an endpoint override is set, else the
/// driver's shared provider. Construction is cheap (one HTTP client), so
/// routed children build per spawn rather than caching.
pub(crate) fn routed_provider(
endpoint: Option<&str>,
api_key: Option<&str>,
parent: &std::sync::Arc<dyn hi_ai::Provider>,
) -> std::sync::Arc<dyn hi_ai::Provider> {
match endpoint.map(str::trim).filter(|url| !url.is_empty()) {
Some(url) => std::sync::Arc::new(hi_ai::OpenAiProvider::new(
url.to_string(),
api_key.unwrap_or_default().to_string(),
)),
None => parent.clone(),
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn explore_child_model_prefers_config_route_over_driver() {
// Env is deliberately not exercised here (global, races other tests);
// it keeps the highest precedence as a live escape hatch.
let mut config = crate::AgentConfig::default();
config.routing.model = "pipe/glm-5.2".into();
assert_eq!(explore_child_model(&config), "pipe/glm-5.2", "inherits");
config.subagents.explore_model = Some("qwen3-4b".into());
assert_eq!(explore_child_model(&config), "qwen3-4b", "team route wins");
config.subagents.explore_model = Some(" ".into());
assert_eq!(
explore_child_model(&config),
"pipe/glm-5.2",
"blank override is ignored"
);
}

#[test]
fn routed_provider_shares_the_driver_unless_an_endpoint_is_set() {
let parent: std::sync::Arc<dyn hi_ai::Provider> = std::sync::Arc::new(
hi_ai::OpenAiProvider::new("http://127.0.0.1:1/v1".into(), "k".into()),
);
let inherited = routed_provider(None, None, &parent);
assert!(
std::sync::Arc::ptr_eq(&parent, &inherited),
"no endpoint → the driver's shared connection"
);
let routed = routed_provider(Some("http://127.0.0.1:18080/v1"), None, &parent);
assert!(
!std::sync::Arc::ptr_eq(&parent, &routed),
"an endpoint override gets its own provider"
);
let blank = routed_provider(Some(" "), None, &parent);
assert!(
std::sync::Arc::ptr_eq(&parent, &blank),
"blank endpoint is ignored"
);
}

#[test]
fn child_prompt_stays_plain_but_has_a_read_only_task_contract() {
let prompt = explore_child_prompt("count the Rust source lines");
Expand Down
Loading
Loading