web_search now speaks the Kimi search service (kimi-cli tools/web/search.py,
wire-verified against api.kimi.com):
- POST {coding_base}/search with {text_query, limit 1-20 (default 5),
enable_page_crawling, timeout_seconds: 30}, OAuth bearer +
X-Msh-Tool-Call-Id; results render in kimi-cli's Title/Date/URL/Summary
schema with result URLs as citations.
- The old implementation called the xAI Responses API with a search model;
that client is fully replaced and the entire model-based config surface
is excised root-and-branch: web_search_model config keys/env/CLI plumbing,
resolve_web_search_sampling_config, toolset web_search SamplerConfig,
RemoteSettings.web_search_model, default_web_search_model.
- Enablement is now purely structural: the service exists only on the Kimi
Code subscription channel, so OAuth sessions get Enabled and API-key-only
sessions get Disabled (tool absent) — per PRD F5.
web_fetch gains the Kimi fetch service as its primary path (kimi-cli
tools/web/fetch.py): POST {coding_base}/fetch with {url}, Accept:
text/markdown, OAuth bearer + X-Msh-Tool-Call-Id; the 200 body is the
extracted markdown (still overflow-budgeted). Any service failure falls
back to the existing local pipeline (SSRF guards, cache, extraction).
The tool gate defaults ON now (kimi-cli always offers FetchURL) and the
egress User-Agent no longer claims grok-agent/x.ai.
Verified end-to-end against the scripted mock service: a headless session
drove web_search (limit/crawling/call-id observed on the wire) then
web_fetch (Accept + call-id observed) to completion.
1697 lines
67 KiB
Rust
1697 lines
67 KiB
Rust
#![cfg_attr(rustfmt, rustfmt::skip)]
|
|
#![allow(unused_imports)]
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use agent_client_protocol as acp;
|
|
use tokio::sync::{Notify, mpsc, oneshot};
|
|
use tokio_util::sync::CancellationToken;
|
|
use crate::extensions::notification::{SessionNotification, SessionUpdate};
|
|
use crate::session::{
|
|
self, SessionCommand, SessionHandle, SessionThread,
|
|
commands::{PromptCompletionKind, PromptTurnResult as SubagentPromptTurnResult},
|
|
fs_watch::FsWatchCapabilities, info::Info as SessionInfo,
|
|
};
|
|
use crate::terminal::AsyncTerminalRunner;
|
|
use crate::tools::ToolContext;
|
|
use kigi_acp_lib::AcpAgentGatewaySender as GatewaySender;
|
|
use kigi_tools::implementations::grok_build::task::types::*;
|
|
use kigi_workspace::file_system::AsyncFileSystem;
|
|
use kigi_hunk_tracker::HunkTrackerHandle;
|
|
use super::*;
|
|
pub(super) fn task_model_override_error(
|
|
requested: Option<&str>,
|
|
provenance: ModelOverrideProvenance,
|
|
is_resume: bool,
|
|
available: &indexmap::IndexMap<String, crate::agent::config::ModelEntry>,
|
|
is_session_auth: bool,
|
|
) -> Option<String> {
|
|
if provenance != ModelOverrideProvenance::Tool || is_resume {
|
|
return None;
|
|
}
|
|
let requested = requested?;
|
|
crate::agent::models::task_model_error_for_catalog(
|
|
requested,
|
|
available,
|
|
is_session_auth,
|
|
)
|
|
}
|
|
/// This is a free async function, NOT a method on MvpAgent. It receives
|
|
/// a `SubagentSpawnContext` with everything it needs, and a mutable
|
|
/// reference to the coordinator for tracking.
|
|
///
|
|
/// Returns when the child session completes (or fails/cancels).
|
|
#[tracing::instrument(
|
|
name = "subagent.handle_request",
|
|
skip_all,
|
|
fields(
|
|
subagent_id = %request.id,
|
|
parent_session_id = %ctx.parent_session_id,
|
|
subagent_type = %request.subagent_type,
|
|
)
|
|
)]
|
|
pub(crate) async fn handle_subagent_request(
|
|
mut request: SubagentRequest,
|
|
mut ctx: SubagentSpawnContext,
|
|
coordinator: &std::cell::RefCell<SubagentCoordinator>,
|
|
gateway: &GatewaySender,
|
|
) {
|
|
let start = std::time::Instant::now();
|
|
let mut parent_wait_guard = (!request.run_in_background)
|
|
.then(|| crate::tools::tool_context::BlockingWaitGuard::enter(
|
|
ctx.parent_blocking_wait_depth.clone(),
|
|
));
|
|
let Some(mut definition) = resolve_agent_definition(&request.subagent_type, &ctx)
|
|
else {
|
|
let msg = format!("Unknown subagent type: {}", request.subagent_type);
|
|
send_pre_spawn_failure(request, &msg, coordinator, &ctx, gateway);
|
|
return;
|
|
};
|
|
match gate_subagent_type(&request.subagent_type, &ctx) {
|
|
SubagentValidateTypeOutcome::Disabled => {
|
|
let msg = format!(
|
|
"Subagent '{}' is disabled via [subagents.toggle] in config.toml",
|
|
request.subagent_type
|
|
);
|
|
send_pre_spawn_failure(request, &msg, coordinator, &ctx, gateway);
|
|
return;
|
|
}
|
|
SubagentValidateTypeOutcome::NotAllowed { allowed } => {
|
|
let msg = format!(
|
|
"agent can only spawn: {}; '{}' not allowed", allowed.join(", "), request
|
|
.subagent_type
|
|
);
|
|
send_pre_spawn_failure(request, &msg, coordinator, &ctx, gateway);
|
|
return;
|
|
}
|
|
_ => {}
|
|
}
|
|
let run_in_background = request.run_in_background
|
|
|| definition.background.unwrap_or(false);
|
|
let cancel_token = CancellationToken::new();
|
|
coordinator
|
|
.borrow_mut()
|
|
.insert_pending(PendingSubagent {
|
|
subagent_id: request.id.clone(),
|
|
subagent_type: request.subagent_type.clone(),
|
|
description: request.description.clone(),
|
|
persona: request.runtime_overrides.persona.clone(),
|
|
parent_prompt_id: request.parent_prompt_id.clone(),
|
|
parent_session_id: ctx.parent_session_id.clone(),
|
|
started_at: start,
|
|
run_in_background,
|
|
surface_completion: request.surface_completion,
|
|
color: definition.color,
|
|
cancel_token: cancel_token.clone(),
|
|
});
|
|
let mut pending_guard = PendingGuard {
|
|
coordinator,
|
|
id: request.id.clone(),
|
|
defused: false,
|
|
error: None,
|
|
};
|
|
resolve_subagent_toolset(
|
|
&request.subagent_type,
|
|
request.runtime_overrides.harness_agent_type.as_deref(),
|
|
&ctx,
|
|
&mut definition,
|
|
);
|
|
let (role, role_key) = {
|
|
let by_type = ctx.subagent_roles.get(&request.subagent_type);
|
|
if by_type.is_some() {
|
|
(by_type, Some(request.subagent_type.clone()))
|
|
} else {
|
|
let by_persona = request
|
|
.runtime_overrides
|
|
.persona
|
|
.as_deref()
|
|
.and_then(|p| ctx.subagent_roles.get(p));
|
|
let key = if by_persona.is_some() {
|
|
request.runtime_overrides.persona.clone()
|
|
} else {
|
|
None
|
|
};
|
|
(by_persona, key)
|
|
}
|
|
};
|
|
let cwd = ctx.parent_session_info.as_ref().map(|i| std::path::Path::new(&i.cwd));
|
|
let effective_runtime = resolve_effective_overrides(
|
|
&request.runtime_overrides,
|
|
role,
|
|
&ctx.subagent_personas,
|
|
cwd,
|
|
role_key,
|
|
);
|
|
let mut effective_runtime = effective_runtime;
|
|
if effective_runtime.reasoning_effort.is_none() {
|
|
effective_runtime.reasoning_effort = definition
|
|
.effort
|
|
.map(|e| <&str>::from(e).to_string());
|
|
}
|
|
{
|
|
use kigi_tool_types::SubagentIsolationMode;
|
|
if effective_runtime.isolation == SubagentIsolationMode::None
|
|
&& definition.isolation
|
|
== Some(kigi_agent::config::IsolationMode::Worktree)
|
|
{
|
|
effective_runtime.isolation = SubagentIsolationMode::Worktree;
|
|
}
|
|
}
|
|
let prompt = request.prompt.clone();
|
|
if let Some(ref err) = effective_runtime.persona_error {
|
|
tracing::error!(
|
|
subagent_id = % request.id, error = err,
|
|
"Persona resolution failed, aborting subagent spawn"
|
|
);
|
|
pending_guard.set_error(err.clone());
|
|
send_failure(request, err);
|
|
return;
|
|
}
|
|
if let Some(ref warn) = effective_runtime.role_prompt_warning {
|
|
tracing::warn!(
|
|
subagent_id = % request.id, warning = warn,
|
|
"Role prompt_file degraded, continuing without role prompt"
|
|
);
|
|
}
|
|
let resume_source = if let Some(resume_id) = request
|
|
.resume_from
|
|
.as_deref()
|
|
.filter(|s| is_valid_resume_id(s))
|
|
{
|
|
let coord = coordinator.borrow();
|
|
if coord.is_active(resume_id) {
|
|
let msg = format!(
|
|
"Cannot resume from subagent '{resume_id}': it is still running. \
|
|
Wait for it to complete before resuming."
|
|
);
|
|
drop(coord);
|
|
send_failure(request, &msg);
|
|
return;
|
|
}
|
|
match coord
|
|
.resumable_source_for(resume_id, &ctx.parent_session_id, &ctx.parent_cwd)
|
|
{
|
|
Some(info) => {
|
|
drop(coord);
|
|
Some(info)
|
|
}
|
|
None => {
|
|
let msg = format!(
|
|
"Cannot resume from subagent '{resume_id}': not found. \
|
|
The subagent may have been evicted or the ID is invalid."
|
|
);
|
|
drop(coord);
|
|
send_failure(request, &msg);
|
|
return;
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
if let Some(ref source) = resume_source {
|
|
if request.runtime_overrides.model.is_some() {
|
|
tracing::debug!(
|
|
subagent_id = % request.id,
|
|
"Ignoring caller model override on resume; source model will be pinned"
|
|
);
|
|
}
|
|
effective_runtime.model = None;
|
|
if let Err(e) = kigi_subagent_resolution::validate_resume_identity(
|
|
&request.subagent_type,
|
|
request.runtime_overrides.persona.as_deref(),
|
|
source,
|
|
) {
|
|
send_failure(request, &e.to_string());
|
|
return;
|
|
}
|
|
}
|
|
if let Some(error) = task_model_override_error(
|
|
request.runtime_overrides.model.as_deref(),
|
|
request.runtime_overrides.model_override_provenance,
|
|
resume_source.is_some(),
|
|
&ctx.available_models,
|
|
ctx.auth_manager.current_or_expired().is_some_and(|a| a.is_session_auth()),
|
|
) {
|
|
pending_guard.set_error(error.clone());
|
|
send_failure(request, &error);
|
|
return;
|
|
}
|
|
let worktree_path = if let Some(ref source) = resume_source {
|
|
if effective_runtime.isolation != kigi_tool_types::SubagentIsolationMode::None
|
|
&& source.worktree_path.is_none()
|
|
{
|
|
tracing::info!(
|
|
subagent_id = % request.id,
|
|
"Ignoring isolation=worktree override: resumed source had no worktree"
|
|
);
|
|
}
|
|
match source.worktree_path.as_deref() {
|
|
None => None,
|
|
Some(dest) => {
|
|
match resume_worktree_action(
|
|
dest.is_dir(),
|
|
source.snapshot_ref.as_deref(),
|
|
) {
|
|
ResumeWorktreeAction::Reuse => Some(dest.to_path_buf()),
|
|
ResumeWorktreeAction::Rehydrate => {
|
|
let snapshot_ref = source
|
|
.snapshot_ref
|
|
.clone()
|
|
.unwrap_or_default();
|
|
let source_repo = resolve_subagent_source_repo(&ctx);
|
|
match crate::session::worktree::rehydrate_subagent_worktree(
|
|
dest,
|
|
&source_repo,
|
|
&snapshot_ref,
|
|
Some(source.subagent_id.as_str()),
|
|
)
|
|
.await
|
|
{
|
|
Ok(path) => {
|
|
tracing::info!(
|
|
subagent_id = % request.id, worktree_path = % path
|
|
.display(), snapshot_ref = % snapshot_ref,
|
|
"Rehydrated subagent worktree from snapshot for resume"
|
|
);
|
|
Some(path)
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
subagent_id = % request.id, error = % e,
|
|
"Failed to rehydrate subagent worktree, falling back to shared workspace"
|
|
);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
ResumeWorktreeAction::Shared => {
|
|
tracing::warn!(
|
|
subagent_id = % request.id, worktree = % dest.display(),
|
|
"Resumed subagent worktree dir missing with no snapshot; using shared workspace"
|
|
);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if effective_runtime.isolation != kigi_tool_types::SubagentIsolationMode::None
|
|
{
|
|
let source_cwd = parent_source_cwd(&ctx);
|
|
let dest = match crate::session::worktree::worktree_base_dir_for_source(
|
|
&source_cwd,
|
|
) {
|
|
Ok(base) => base.join(format!("subagent-{}", request.id)),
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
subagent_id = % request.id, error = % e,
|
|
"Could not resolve worktree base dir, using temp dir for subagent worktree"
|
|
);
|
|
std::env::temp_dir().join("grok-subagent-worktrees").join(&request.id)
|
|
}
|
|
};
|
|
let source_clone = source_cwd;
|
|
let subagent_id = request.id.clone();
|
|
let creation_mode: kigi_fast_worktree::CreationMode = ctx.worktree_type.into();
|
|
let btrfs_delegate = crate::session::worktree::btrfs_delegate_from_env();
|
|
match tokio::task::spawn_blocking(move || {
|
|
let mut builder = kigi_fast_worktree::WorktreeBuilder::new(
|
|
&source_clone,
|
|
&dest,
|
|
)
|
|
.working_tree_mode(
|
|
kigi_fast_worktree::WorkingTreeMode::PreserveWorkingTree,
|
|
)
|
|
.creation_mode(creation_mode)
|
|
.worktree_kind(kigi_fast_worktree::WorktreeKind::Subagent)
|
|
.session_id(subagent_id);
|
|
if let Some(delegate) = btrfs_delegate {
|
|
builder = builder.btrfs_delegate(delegate);
|
|
}
|
|
builder.create()
|
|
})
|
|
.await
|
|
{
|
|
Ok(Ok(report)) => {
|
|
tracing::info!(
|
|
subagent_id = % request.id, worktree_path = % report.worktree_path
|
|
.display(), commit = % report.commit,
|
|
"Created isolated worktree for subagent"
|
|
);
|
|
Some(report.worktree_path)
|
|
}
|
|
Ok(Err(e)) => {
|
|
tracing::warn!(
|
|
subagent_id = % request.id, error = % e,
|
|
"Failed to create worktree, falling back to shared workspace"
|
|
);
|
|
None
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
subagent_id = % request.id, error = % e,
|
|
"Worktree creation task panicked, falling back to shared workspace"
|
|
);
|
|
None
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
let worktree_freshly_created = resume_source.is_none() && worktree_path.is_some();
|
|
if let Some(raw_cwd) = request.cwd.as_deref() {
|
|
match sanitize_cwd_value(raw_cwd) {
|
|
Some(cwd_path) => {
|
|
if worktree_path.is_none() && resume_source.is_none() {
|
|
let p = Path::new(&cwd_path);
|
|
if !p.is_dir() {
|
|
let msg = if p.exists() {
|
|
format!("cwd \"{cwd_path}\" exists but is not a directory")
|
|
} else {
|
|
format!("cwd \"{cwd_path}\" does not exist")
|
|
};
|
|
send_failure(request, &msg);
|
|
return;
|
|
}
|
|
}
|
|
request.cwd = Some(cwd_path);
|
|
}
|
|
None => request.cwd = None,
|
|
}
|
|
}
|
|
if effective_runtime.reasoning_effort.is_some()
|
|
|| effective_runtime.capability_mode.is_some()
|
|
{
|
|
tracing::info!(
|
|
subagent_id = % request.id, reasoning_effort = ? effective_runtime
|
|
.reasoning_effort, capability_mode = ? effective_runtime.capability_mode,
|
|
"Resolved runtime overrides for subagent"
|
|
);
|
|
}
|
|
if let Some(mode) = effective_runtime.capability_mode {
|
|
mode.filter_tool_config(&mut definition.tool_config);
|
|
tracing::info!(
|
|
subagent_id = % request.id, capability_mode = ? mode, tools_remaining =
|
|
definition.tool_config.tools.len(),
|
|
"Applied capability mode filter to agent tool config"
|
|
);
|
|
}
|
|
{
|
|
use kigi_tools::implementations::grok_build::task::MAX_SUBAGENT_DEPTH;
|
|
use kigi_tools::types::tool::ToolKind;
|
|
let child_depth = ctx.parent_depth + 1;
|
|
if child_depth >= MAX_SUBAGENT_DEPTH {
|
|
let before = definition.tool_config.tools.len();
|
|
definition.tool_config.tools.retain(|tc| tc.kind != Some(ToolKind::Task));
|
|
if definition.tool_config.tools.len() < before {
|
|
tracing::info!(
|
|
subagent_id = % request.id, child_depth, max_depth =
|
|
MAX_SUBAGENT_DEPTH, "Stripped task tool from child at max depth"
|
|
);
|
|
}
|
|
prune_orphaned_background_task_tools(&mut definition.tool_config);
|
|
}
|
|
}
|
|
if request.fork_context {
|
|
effective_runtime.model = Some(ctx.model_id.0.to_string());
|
|
}
|
|
let (mut effective_sampling_config, mut effective_model_id) = resolve_effective_model_config(
|
|
effective_runtime.model.as_deref(),
|
|
&request.subagent_type,
|
|
&definition.model,
|
|
&ctx,
|
|
)
|
|
.await;
|
|
let subagent_max_turns = resolve_subagent_max_turns(
|
|
definition.max_turns,
|
|
ctx.parent_max_turns,
|
|
);
|
|
{
|
|
let model_str = &effective_sampling_config.model;
|
|
let model_unknown = !model_str.is_empty() && !ctx.available_models.is_empty()
|
|
&& !ctx.available_models.contains_key(model_str)
|
|
&& !ctx.available_models.values().any(|e| e.info().model == *model_str);
|
|
if model_unknown {
|
|
let (parent_config, parent_mid) = read_parent_sampling_config(&ctx).await;
|
|
tracing::warn!(
|
|
subagent_id = % request.id, resolved_model = % model_str, parent_model =
|
|
% parent_config.model,
|
|
"Resolved subagent model not found in available models — \
|
|
falling back to parent model"
|
|
);
|
|
effective_sampling_config = parent_config;
|
|
effective_model_id = parent_mid;
|
|
}
|
|
}
|
|
if let Some(ref source) = resume_source
|
|
&& let Some(ref source_model) = source.model_id
|
|
&& effective_model_id.0.as_ref() != source_model.as_str()
|
|
{
|
|
if let Some(resolved) = resolve_model_override_to_config(source_model, &ctx) {
|
|
tracing::info!(
|
|
subagent_id = % request.id, resolved_model = % effective_model_id.0,
|
|
source_model = source_model, "Pinning resumed child to source model"
|
|
);
|
|
effective_sampling_config = resolved.0;
|
|
effective_model_id = resolved.1;
|
|
} else {
|
|
let msg = format!(
|
|
"Cannot resume from subagent '{}': source model '{source_model}' \
|
|
is no longer available in the model catalogue.",
|
|
source.subagent_id,
|
|
);
|
|
send_failure(request, &msg);
|
|
return;
|
|
}
|
|
}
|
|
if let Some(raw) = effective_runtime.reasoning_effort.as_deref()
|
|
&& ctx
|
|
.models_manager
|
|
.model_supports_reasoning_effort(effective_model_id.0.as_ref())
|
|
{
|
|
use kigi_sampling_types::ReasoningEffort;
|
|
match raw.parse::<ReasoningEffort>() {
|
|
Ok(eff) => effective_sampling_config.reasoning_effort = Some(eff),
|
|
Err(err) => {
|
|
tracing::warn!(
|
|
value = raw, error = % err,
|
|
"subagent reasoning_effort: parse failed, ignoring override"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
let subagent_id = request.id.clone();
|
|
let child_session_id = acp::SessionId::new(subagent_id.clone());
|
|
let override_cwd = select_override_cwd(
|
|
resume_source.as_ref(),
|
|
request.cwd.as_deref(),
|
|
);
|
|
let effective_cwd = resolve_child_cwd(
|
|
worktree_path.as_deref(),
|
|
override_cwd,
|
|
&ctx.parent_cwd,
|
|
)
|
|
.to_string_lossy()
|
|
.into_owned();
|
|
let child_session_info = SessionInfo {
|
|
id: child_session_id.clone(),
|
|
cwd: effective_cwd,
|
|
};
|
|
let child_session_dir = session::persistence::session_dir(&child_session_info);
|
|
let parent_session_dir = session::persistence::session_dir(
|
|
&SessionInfo {
|
|
id: acp::SessionId::new(ctx.parent_session_id.clone()),
|
|
cwd: ctx.parent_cwd.to_string_lossy().to_string(),
|
|
},
|
|
);
|
|
let subagent_meta_dir = parent_session_dir.join("subagents").join(&subagent_id);
|
|
let InitialContext {
|
|
source: context_source,
|
|
copy_error: fork_copy_error,
|
|
prefix_len: inherited_prefix_len,
|
|
conversation: forked_conversation,
|
|
verbatim_fork: context_verbatim_fork,
|
|
} = match bootstrap_initial_context(
|
|
&request,
|
|
resume_source.as_ref(),
|
|
&ctx,
|
|
&child_session_info,
|
|
&child_session_dir,
|
|
effective_model_id.0.as_ref(),
|
|
effective_sampling_config.context_window,
|
|
)
|
|
.await
|
|
{
|
|
BootstrapInitialContext::Ready(ctx) => ctx,
|
|
BootstrapInitialContext::ResumeAbort(msg) => {
|
|
tracing::error!(
|
|
subagent_id = % request.id, error = % msg,
|
|
"Resume-copy failed, aborting subagent spawn"
|
|
);
|
|
send_failure(request, &msg);
|
|
return;
|
|
}
|
|
};
|
|
let verbatim_mirror_fork = context_source == InitialContextSource::Forked
|
|
&& context_verbatim_fork;
|
|
let task_prompt_text = prompt.clone();
|
|
let (mut forked_conversation, mut inherited_prefix_len) = (
|
|
forked_conversation,
|
|
inherited_prefix_len.unwrap_or(0),
|
|
);
|
|
if crate::session::is_cursor_user_template(&definition.user_message_template)
|
|
&& context_source != InitialContextSource::Resumed && !verbatim_mirror_fork
|
|
{} else if context_source != InitialContextSource::Resumed && !verbatim_mirror_fork
|
|
&& let Some(ref pi) = effective_runtime.persona_instructions {
|
|
let reminder = kigi_sampling_types::conversation::ConversationItem::system_reminder(
|
|
format!("<system-reminder>\n{pi}\n</system-reminder>"),
|
|
);
|
|
let insert_at = inherited_prefix_len.min(forked_conversation.len());
|
|
forked_conversation.insert(insert_at, reminder);
|
|
inherited_prefix_len += 1;
|
|
}
|
|
let effective_source_str = match &context_source {
|
|
InitialContextSource::New => "new",
|
|
InitialContextSource::Forked => "forked",
|
|
InitialContextSource::Resumed => "resumed",
|
|
};
|
|
let subagent_meta = SubagentMeta {
|
|
subagent_id: subagent_id.clone(),
|
|
parent_session_id: ctx.parent_session_id.clone(),
|
|
child_session_id: child_session_id.0.to_string(),
|
|
subagent_type: request.subagent_type.clone(),
|
|
description: request.description.clone(),
|
|
prompt: request.prompt.clone(),
|
|
status: "running".to_string(),
|
|
started_at: chrono::Utc::now(),
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
tool_calls: None,
|
|
turns: None,
|
|
error: None,
|
|
effective_context_source: Some(effective_source_str.to_string()),
|
|
context_normalized: fork_context_normalized(
|
|
&context_source,
|
|
context_verbatim_fork,
|
|
),
|
|
fork_copy_error: fork_copy_error.clone(),
|
|
persona: effective_runtime.persona.clone(),
|
|
resumed_from: request.resume_from.clone(),
|
|
child_cwd: Some(child_session_info.cwd.clone()),
|
|
worktree_path: worktree_path.as_ref().map(|p| p.to_string_lossy().to_string()),
|
|
snapshot_ref: None,
|
|
effective_model_id: Some(effective_model_id.0.to_string()),
|
|
};
|
|
write_subagent_meta(&subagent_meta_dir, &subagent_meta);
|
|
emit_subagent_notification(
|
|
gateway,
|
|
&ctx.parent_session_id,
|
|
SessionUpdate::SubagentSpawned {
|
|
subagent_id: subagent_id.clone(),
|
|
child_session_id: child_session_id.0.to_string(),
|
|
parent_session_id: ctx.parent_session_id.clone(),
|
|
parent_prompt_id: request.parent_prompt_id.clone(),
|
|
subagent_type: request.subagent_type.clone(),
|
|
description: request.description.clone(),
|
|
effective_context_source: Some(effective_source_str.to_string()),
|
|
context_normalized: fork_context_normalized(
|
|
&context_source,
|
|
context_verbatim_fork,
|
|
),
|
|
capability_mode: effective_runtime
|
|
.capability_mode
|
|
.and_then(|m| serde_json::to_value(m).ok())
|
|
.and_then(|v| v.as_str().map(String::from)),
|
|
persona: effective_runtime.persona.clone(),
|
|
role: effective_runtime.role_name.clone(),
|
|
model: Some(effective_model_id.0.to_string()),
|
|
resumed_from: request.resume_from.clone(),
|
|
},
|
|
ctx.parent_cmd_tx.as_ref(),
|
|
);
|
|
let sampling_client = match crate::sampling::Client::new(
|
|
effective_sampling_config.clone(),
|
|
) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
let msg = format!("Sampling client error: {e}");
|
|
pending_guard.set_error(msg.clone());
|
|
fail_subagent(
|
|
request,
|
|
&msg,
|
|
&subagent_id,
|
|
&child_session_id,
|
|
&subagent_meta_dir,
|
|
gateway,
|
|
&ctx.parent_session_id,
|
|
ctx.parent_cmd_tx.as_ref(),
|
|
0,
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
let persistence = match session::persistence::new_with_explicit_dir(
|
|
&child_session_info,
|
|
child_session_dir.clone(),
|
|
effective_model_id.clone(),
|
|
sampling_client,
|
|
effective_sampling_config.model.clone(),
|
|
)
|
|
.await
|
|
{
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
let msg = format!("Persistence error: {e}");
|
|
pending_guard.set_error(msg.clone());
|
|
fail_subagent(
|
|
request,
|
|
&msg,
|
|
&subagent_id,
|
|
&child_session_id,
|
|
&subagent_meta_dir,
|
|
gateway,
|
|
&ctx.parent_session_id,
|
|
ctx.parent_cmd_tx.as_ref(),
|
|
0,
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
let child_cwd = resolve_child_cwd(
|
|
worktree_path.as_deref(),
|
|
override_cwd,
|
|
&ctx.parent_cwd,
|
|
);
|
|
let cwd_outside_parent = match (
|
|
dunce::canonicalize(&child_cwd),
|
|
dunce::canonicalize(&ctx.parent_cwd),
|
|
) {
|
|
(Ok(child), Ok(parent)) => !child.starts_with(&parent),
|
|
_ => child_cwd != ctx.parent_cwd,
|
|
};
|
|
let subagent_fs_watch = FsWatchCapabilities {
|
|
hunk_tracking: ctx.hunk_tracking_enabled && cwd_outside_parent,
|
|
..FsWatchCapabilities::none()
|
|
};
|
|
let child_cwd_abs = kigi_paths::AbsPathBuf::new(child_cwd)
|
|
.unwrap_or_else(|_| {
|
|
kigi_paths::AbsPathBuf::new(std::env::current_dir().unwrap_or_default())
|
|
.expect("current_dir should be absolute")
|
|
});
|
|
let mut tool_ctx = ToolContext::with_preloaded_env(
|
|
child_cwd_abs,
|
|
Some(gateway.clone()),
|
|
Some(child_session_id.clone()),
|
|
ctx.fs.clone(),
|
|
ctx.terminal.clone(),
|
|
ctx.hunk_tracker_handle.clone(),
|
|
(*ctx.session_env).clone(),
|
|
)
|
|
.with_hunk_tracking_enabled(ctx.hunk_tracking_enabled);
|
|
tool_ctx.subagent_event_tx = Some(ctx.subagent_event_tx.clone());
|
|
tool_ctx.monitor_event_buffer = Some(MonitorEventBuffer::default());
|
|
tool_ctx.subagent_depth = ctx.parent_depth + 1;
|
|
tool_ctx.lsp = ctx.lsp.clone();
|
|
let parent_traceparent = kigi_file_utils::trace_context::current_traceparent();
|
|
let tracker_child_cwd = child_session_info.cwd.clone();
|
|
let tracker_model_id = effective_model_id.0.to_string();
|
|
let initial_child_tokens = kigi_chat_state::estimate_conversation_tokens(
|
|
&forked_conversation,
|
|
);
|
|
let model_entry = crate::agent::config::find_model_by_id(
|
|
&ctx.available_models,
|
|
effective_model_id.0.as_ref(),
|
|
);
|
|
let model_has_own_creds = model_entry
|
|
.is_some_and(|entry| entry.has_own_credentials());
|
|
let inherited_auth_type = subagent_auth_type(model_entry, &ctx.auth_method_id);
|
|
let credentials = kigi_chat_state::Credentials {
|
|
api_key: effective_sampling_config.api_key.clone(),
|
|
auth_type: inherited_auth_type,
|
|
alpha_test_key: ctx.alpha_test_key.clone(),
|
|
};
|
|
kigi_log::unified_log::info(
|
|
"subagent spawn credentials",
|
|
None,
|
|
Some(
|
|
serde_json::json!(
|
|
{ "subagent_id" : & request.id, "subagent_type" : & request
|
|
.subagent_type, "effective_model" : effective_model_id.0.as_ref(),
|
|
"effective_model_raw" : & effective_sampling_config.model, "base_url" : &
|
|
effective_sampling_config.base_url, "key_prefix" : key_prefix(&
|
|
effective_sampling_config.api_key), "auth_type" : format!("{:?}",
|
|
inherited_auth_type), "model_has_own_creds" : model_has_own_creds,
|
|
"auth_method_id" : ctx.auth_method_id.0.as_ref(), "parent_model" : ctx
|
|
.model_id.0.as_ref(), "parent_key_prefix" : key_prefix(& ctx
|
|
.sampling_config.api_key), "context_window" : effective_sampling_config
|
|
.context_window, }
|
|
),
|
|
),
|
|
);
|
|
let attribution_callback: Option<kigi_sampler::SharedAttributionCallback> = effective_sampling_config
|
|
.attribution_callback
|
|
.clone();
|
|
let tracker_color = definition.color;
|
|
let agent_memory_scope = definition.memory;
|
|
let agent_name_for_memory = definition.name.clone();
|
|
let is_plugin_agent = definition.plugin_name.is_some();
|
|
let yolo_policy_block = kigi_workspace::permission::resolution::yolo_disabled_by_policy();
|
|
let agent_permission_mode = resolve_subagent_permission_mode(
|
|
definition.permission_mode.clone(),
|
|
is_plugin_agent,
|
|
yolo_policy_block,
|
|
);
|
|
if agent_permission_mode != definition.permission_mode {
|
|
if is_plugin_agent {
|
|
tracing::warn!(
|
|
agent = % definition.name, plugin = ? definition.plugin_name,
|
|
"ignoring permissionMode on plugin agent (not supported for security)"
|
|
);
|
|
} else {
|
|
tracing::warn!(
|
|
agent = % definition.name,
|
|
"ignoring subagent permissionMode=bypassPermissions: always-approve disabled by managed policy"
|
|
);
|
|
}
|
|
}
|
|
if let Some(scope) = agent_memory_scope {
|
|
use kigi_tools::implementations::grok_build;
|
|
use kigi_tools::implementations::opencode;
|
|
let memory_tools: Vec<kigi_tools::registry::types::ToolConfig> = vec![
|
|
(& grok_build::ReadFileTool).into(), (& grok_build::SearchReplaceTool)
|
|
.into(), (& opencode::OpenCodeWriteTool).into(),
|
|
];
|
|
for tc in memory_tools {
|
|
if !definition.tool_config.tools.iter().any(|t| t.id == tc.id) {
|
|
definition.tool_config.tools.push(tc);
|
|
}
|
|
}
|
|
let resolved_mem = scope.resolve_dir(&agent_name_for_memory, &ctx.parent_cwd);
|
|
let memory_dir = &resolved_mem.path;
|
|
let memory_md = memory_dir.join("MEMORY.md");
|
|
if memory_md.is_file() && let Ok(content) = std::fs::read_to_string(&memory_md) {
|
|
const MAX_LINES: usize = 200;
|
|
const MAX_BYTES: usize = 25 * 1024;
|
|
let truncated: String = content
|
|
.lines()
|
|
.take(MAX_LINES)
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
let truncated = kigi_tools::util::truncate::truncate_str(
|
|
&truncated,
|
|
MAX_BYTES,
|
|
)
|
|
.to_string();
|
|
if !truncated.is_empty() {
|
|
let injection = format!(
|
|
"\n\n<agent-memory>\nMemory directory: {}\n\n{truncated}\n</agent-memory>",
|
|
memory_dir.display()
|
|
);
|
|
definition.prompt_body = Some(
|
|
definition.prompt_body.unwrap_or_default() + &injection,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
let is_plugin_agent = definition.plugin_name.is_some();
|
|
if let Some(ref hooks_config) = definition.hooks {
|
|
if is_plugin_agent {
|
|
tracing::warn!(
|
|
agent = % definition.name, plugin = ? definition.plugin_name,
|
|
"ignoring hooks on plugin agent (not supported for security)"
|
|
);
|
|
} else if !crate::agent::folder_trust::agent_inline_hooks_allowed(
|
|
definition.scope,
|
|
|| crate::agent::folder_trust::project_scope_allowed(&ctx.parent_cwd),
|
|
) {
|
|
tracing::warn!(
|
|
agent = % definition.name,
|
|
"ignoring hooks on untrusted project agent (folder not trusted; re-run with --trust)"
|
|
);
|
|
} else {
|
|
let hooks_val = hooks_config.as_value();
|
|
let (specs, errors) = kigi_hooks::config::parse_hooks_from_value_with_dir(
|
|
&hooks_val,
|
|
&format!("agent:{}", definition.name),
|
|
&ctx.parent_cwd,
|
|
);
|
|
for e in &errors {
|
|
tracing::warn!(
|
|
agent = % definition.name, error = ? e, "agent hook parse error"
|
|
);
|
|
}
|
|
if !specs.is_empty() {
|
|
let specs: Vec<_> = specs
|
|
.into_iter()
|
|
.map(|mut s| {
|
|
if s.event == kigi_hooks::event::HookEventName::Stop {
|
|
s.event = kigi_hooks::event::HookEventName::SubagentStop;
|
|
}
|
|
s
|
|
})
|
|
.collect();
|
|
let mut registry = ctx
|
|
.hook_registry
|
|
.as_ref()
|
|
.map(|r| (**r).clone())
|
|
.unwrap_or_default();
|
|
registry.append_specs(specs);
|
|
ctx.hook_registry = Some(std::sync::Arc::new(registry));
|
|
}
|
|
}
|
|
}
|
|
let agent_mcp_servers: Vec<_> = if is_plugin_agent {
|
|
if !definition.mcp_servers.is_empty() {
|
|
tracing::warn!(
|
|
agent = % definition.name, plugin = ? definition.plugin_name,
|
|
"ignoring mcpServers on plugin agent (not supported for security)"
|
|
);
|
|
}
|
|
vec![]
|
|
} else {
|
|
definition
|
|
.mcp_servers
|
|
.iter()
|
|
.filter_map(|entry| match entry {
|
|
kigi_agent::config::McpServerRef::Named(name) => {
|
|
ctx.parent_mcp_configs
|
|
.iter()
|
|
.find(|s| {
|
|
crate::session::mcp_servers::mcp_server_name(s) == name
|
|
})
|
|
.cloned()
|
|
.or_else(|| {
|
|
tracing::warn!(
|
|
agent = % definition.name, server = name,
|
|
"mcpServers: named ref not found in parent"
|
|
);
|
|
None
|
|
})
|
|
}
|
|
kigi_agent::config::McpServerRef::Inline { name, config } => {
|
|
if let serde_json::Value::Object(obj) = config
|
|
&& obj.contains_key("type")
|
|
{
|
|
let mut flat = obj.clone();
|
|
flat.insert(
|
|
"name".to_string(),
|
|
serde_json::Value::String(name.clone()),
|
|
);
|
|
if let Ok(server) = serde_json::from_value::<
|
|
agent_client_protocol::McpServer,
|
|
>(serde_json::Value::Object(flat)) {
|
|
return Some(server);
|
|
}
|
|
tracing::debug!(
|
|
agent = % definition.name, server = name,
|
|
"ACP wire format parse failed, trying map-keyed"
|
|
);
|
|
}
|
|
if let Some(inner_obj) = config.as_object() {
|
|
let mut flat = inner_obj.clone();
|
|
flat.insert(
|
|
"name".to_string(),
|
|
serde_json::Value::String(name.clone()),
|
|
);
|
|
if let Ok(server) = serde_json::from_value::<
|
|
agent_client_protocol::McpServer,
|
|
>(serde_json::Value::Object(flat)) {
|
|
return Some(server);
|
|
}
|
|
}
|
|
tracing::warn!(
|
|
agent = % definition.name, server = name,
|
|
"mcpServers: inline config could not be parsed"
|
|
);
|
|
None
|
|
}
|
|
})
|
|
.collect()
|
|
};
|
|
let parent_mcp_pool = if is_plugin_agent {
|
|
if ctx.parent_mcp_pool.is_some() {
|
|
tracing::debug!(
|
|
agent = % definition.name,
|
|
"skipping MCP pool inheritance for plugin agent"
|
|
);
|
|
}
|
|
None
|
|
} else {
|
|
ctx.parent_mcp_pool
|
|
.take()
|
|
.and_then(|pool| filter_pool_by_inheritance(
|
|
pool,
|
|
&definition.mcp_inheritance,
|
|
))
|
|
};
|
|
let mcp_inherited_count = parent_mcp_pool
|
|
.as_ref()
|
|
.map(|p| p.len() as u32)
|
|
.unwrap_or(0);
|
|
if mcp_inherited_count > 0 {
|
|
tracing::info!(
|
|
subagent_id = % request.id, mcp_count = mcp_inherited_count,
|
|
"Subagent inherited MCP servers from parent pool"
|
|
);
|
|
}
|
|
let inherit_skills = definition.inherit_skills;
|
|
if inherit_skills && ctx.parent_skills.is_none() {
|
|
let parent_cwd_str = ctx.parent_cwd.to_string_lossy().to_string();
|
|
ctx.parent_skills = Some(
|
|
kigi_agent::prompt::skills::list_skills_with_plugins(
|
|
Some(&parent_cwd_str),
|
|
&ctx.parent_skills_config,
|
|
ctx.plugin_registry.as_deref(),
|
|
ctx.parent_compat,
|
|
)
|
|
.await,
|
|
);
|
|
}
|
|
let skills_inherited_count = if inherit_skills {
|
|
ctx.parent_skills.as_ref().map(|s| s.len() as u32).unwrap_or(0)
|
|
} else {
|
|
0
|
|
};
|
|
if skills_inherited_count > 0 {
|
|
tracing::info!(
|
|
subagent_id = % request.id, skills_count = skills_inherited_count,
|
|
"Subagent inherited skills from parent"
|
|
);
|
|
}
|
|
let mcp_owned_count = agent_mcp_servers.len() as u32;
|
|
let subagent_session_default_agent_profile = Some(definition.name.clone());
|
|
let subagent_model_id = effective_sampling_config.model.clone();
|
|
let _ = persistence
|
|
.tx
|
|
.send(crate::session::persistence::PersistenceMsg::CurrentModel {
|
|
model_id: effective_model_id.clone(),
|
|
agent_name: Some(definition.name.clone()),
|
|
reasoning_effort: Some(effective_sampling_config.reasoning_effort),
|
|
});
|
|
let forked_tool_override = if verbatim_mirror_fork {
|
|
ctx.parent_tool_snapshot.clone()
|
|
} else {
|
|
None
|
|
};
|
|
let spawn_result = session::spawn_session_on_thread(
|
|
child_session_info,
|
|
gateway.clone(),
|
|
effective_sampling_config,
|
|
credentials,
|
|
crate::agent::auth_method::new_shared_auth_method_id(
|
|
Some(ctx.auth_method_id.clone()),
|
|
),
|
|
Some(ctx.auth_manager.clone()),
|
|
attribution_callback,
|
|
tool_ctx,
|
|
agent_mcp_servers,
|
|
vec![],
|
|
Default::default(),
|
|
parent_mcp_pool,
|
|
Vec::new(),
|
|
true,
|
|
None,
|
|
persistence,
|
|
forked_conversation,
|
|
None,
|
|
None,
|
|
initial_child_tokens,
|
|
crate::session::StartupHints {
|
|
inherited_prefix_len: Some(inherited_prefix_len),
|
|
is_subagent: true,
|
|
parent_session_id: Some(ctx.parent_session_id.clone()),
|
|
subagent_type: Some(request.subagent_type.clone()),
|
|
preserve_inherited_system: verbatim_mirror_fork,
|
|
..Default::default()
|
|
},
|
|
kigi_workspace::permission::ClientType::Generic,
|
|
ctx.resolve_auto_compact_threshold_percent(&subagent_model_id),
|
|
kigi_agent::DEFAULT_SYSTEM_PROMPT_LABEL.to_string(),
|
|
kigi_chat_state::CompactionMode::Summary,
|
|
ctx.resolve_compaction_verbatim_input(),
|
|
false,
|
|
None,
|
|
None,
|
|
std::sync::Arc::new(
|
|
parking_lot::Mutex::new(
|
|
kigi_workspace::file_system::CodebaseIndexManager::new(),
|
|
),
|
|
),
|
|
false,
|
|
subagent_fs_watch,
|
|
None,
|
|
false,
|
|
false,
|
|
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)),
|
|
definition,
|
|
subagent_session_default_agent_profile,
|
|
if inherit_skills {
|
|
ctx.parent_skills_config.clone()
|
|
} else {
|
|
kigi_agent::prompt::skills::SkillsConfig::default()
|
|
},
|
|
if inherit_skills { ctx.parent_skills.take() } else { None },
|
|
ctx.parent_compat,
|
|
false,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
if verbatim_mirror_fork {
|
|
None
|
|
} else if let Some(scope) = agent_memory_scope {
|
|
ctx.memory_config
|
|
.as_ref()
|
|
.map(|mc| {
|
|
let mut c = mc.clone();
|
|
let resolved = scope
|
|
.resolve_dir(&agent_name_for_memory, &ctx.parent_cwd);
|
|
c.enabled = true;
|
|
c.root_dir_override = Some(resolved.path);
|
|
c.flat_memory_root = resolved.is_project_scoped;
|
|
c
|
|
})
|
|
} else {
|
|
ctx.memory_config.clone()
|
|
},
|
|
Default::default(),
|
|
ctx.managed_mcp_state.clone(),
|
|
None,
|
|
ctx.managed_mcp_proxy_base_url.clone(),
|
|
effective_model_id,
|
|
ctx.yolo_mode
|
|
|| matches!(
|
|
agent_permission_mode,
|
|
kigi_agent::config::PermissionMode::BypassPermissions
|
|
),
|
|
false,
|
|
None,
|
|
ctx.inference_idle_timeout_secs,
|
|
None,
|
|
ctx.web_search_config.clone(),
|
|
ctx.web_fetch_config.clone(),
|
|
ctx.image_gen_config.clone(),
|
|
ctx.video_gen_config.clone(),
|
|
ctx.app_builder_deployer_config.clone(),
|
|
ctx.write_file_enabled,
|
|
ctx.goal_enabled,
|
|
true,
|
|
ctx.ask_user_question_enabled,
|
|
ctx.client_hooks.clone(),
|
|
None,
|
|
std::collections::HashMap::new(),
|
|
ctx.persona_io_summaries.clone(),
|
|
kigi_agent::prompt::context::PromptAudience::Subagent,
|
|
effective_runtime.role_prompt.clone(),
|
|
None,
|
|
ctx.disable_web_search,
|
|
ctx.backend_tools_enabled,
|
|
ctx.respect_gitignore,
|
|
ctx.path_not_found_hints,
|
|
ctx.resolve_tool_params_json(),
|
|
ctx.plugin_registry.clone(),
|
|
None,
|
|
ctx.models_manager.clone(),
|
|
parent_traceparent,
|
|
ctx.permission_handle.clone(),
|
|
ctx.api_key_provider.clone(),
|
|
ctx.image_description_model.clone(),
|
|
ctx.hook_registry.clone(),
|
|
ctx.workspace_ops.clone(),
|
|
vec![],
|
|
ctx.todo_gate,
|
|
std::mem::take(&mut ctx.remote_settings),
|
|
std::mem::take(&mut ctx.laziness_debug_log),
|
|
ctx.parent_terminal_backend.clone(),
|
|
ctx.parent_scheduler_handle.clone(),
|
|
subagent_max_turns,
|
|
forked_tool_override,
|
|
)
|
|
.await;
|
|
let (child_handle, _system_prompt, child_thread) = match spawn_result {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
let msg = format!("Failed to spawn child session: {e}");
|
|
pending_guard.set_error(msg.clone());
|
|
fail_subagent(
|
|
request,
|
|
&msg,
|
|
&subagent_id,
|
|
&child_session_id,
|
|
&subagent_meta_dir,
|
|
gateway,
|
|
&ctx.parent_session_id,
|
|
ctx.parent_cmd_tx.as_ref(),
|
|
start.elapsed().as_millis() as u64,
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
if cancel_token.is_cancelled() {
|
|
pending_guard.defuse();
|
|
cancel_pending_subagent_at_promote(
|
|
request,
|
|
&child_handle,
|
|
&subagent_id,
|
|
&child_session_id,
|
|
&subagent_meta_dir,
|
|
coordinator,
|
|
gateway,
|
|
&ctx.parent_session_id,
|
|
ctx.parent_cmd_tx.as_ref(),
|
|
worktree_path.as_deref(),
|
|
worktree_freshly_created,
|
|
start.elapsed().as_millis() as u64,
|
|
)
|
|
.await;
|
|
return;
|
|
}
|
|
pending_guard.defuse();
|
|
coordinator
|
|
.borrow_mut()
|
|
.insert(SubagentTracker {
|
|
subagent_id: request.id.clone(),
|
|
parent_session_id: ctx.parent_session_id.clone(),
|
|
parent_prompt_id: request.parent_prompt_id.clone(),
|
|
child_session_id: child_session_id.clone(),
|
|
subagent_type: request.subagent_type.clone(),
|
|
persona: effective_runtime.persona.clone(),
|
|
description: request.description.clone(),
|
|
started_at: start,
|
|
child_handle: child_handle.clone(),
|
|
child_thread,
|
|
cancel_token: cancel_token.clone(),
|
|
resumed_from: request.resume_from.clone(),
|
|
child_cwd: tracker_child_cwd,
|
|
worktree_path: worktree_path.clone(),
|
|
effective_model_id: tracker_model_id,
|
|
run_in_background,
|
|
surface_completion: request.surface_completion,
|
|
color: tracker_color,
|
|
block_waited: false,
|
|
explicitly_killed: false,
|
|
});
|
|
spawn_progress_publisher(
|
|
child_handle.signals_handle.clone(),
|
|
gateway.clone(),
|
|
ctx.parent_session_id.clone(),
|
|
request.id.clone(),
|
|
child_session_id.0.to_string(),
|
|
start,
|
|
cancel_token.clone(),
|
|
goal_tick_cmd_tx(ctx.goal_enabled, ctx.parent_cmd_tx.as_ref()),
|
|
);
|
|
let (prompt_tx, prompt_rx) = oneshot::channel();
|
|
let prompt_text = task_prompt_text;
|
|
let child_prompt_id = uuid::Uuid::now_v7().to_string();
|
|
let _ = child_handle
|
|
.cmd_tx
|
|
.send(SessionCommand::Prompt {
|
|
prompt_id: child_prompt_id.clone(),
|
|
prompt_blocks: vec![
|
|
acp::ContentBlock::Text(acp::TextContent::new(prompt_text))
|
|
],
|
|
prompt_mode: crate::session::plan_mode::PromptMode::Agent,
|
|
client_identifier: None,
|
|
screen_mode: None,
|
|
verbatim: true,
|
|
traceparent: kigi_file_utils::trace_context::current_traceparent(),
|
|
json_schema: None,
|
|
send_now: false,
|
|
respond_to: prompt_tx,
|
|
persist_ack: None,
|
|
});
|
|
let mut result_tx = {
|
|
let (dummy_tx, _) = oneshot::channel();
|
|
Some(std::mem::replace(&mut request.result_tx, dummy_tx))
|
|
};
|
|
let wait_outcome = {
|
|
let fut = await_subagent_turn_or_cancellation(prompt_rx, cancel_token.clone());
|
|
tokio::pin!(fut);
|
|
if !request.run_in_background {
|
|
/// How the bounded foreground wait ended.
|
|
enum ForegroundWait {
|
|
/// The child finished (or was cancelled) within the budget.
|
|
Done(SubagentWaitOutcome),
|
|
/// The spawning tool's `result_rx` was dropped — parent turn died mid-await.
|
|
ParentGone,
|
|
/// `subagent_await_budget()` expired.
|
|
Budget,
|
|
}
|
|
let first = {
|
|
let parent_await_dropped = async {
|
|
match result_tx.as_mut() {
|
|
Some(tx) => tx.closed().await,
|
|
None => std::future::pending::<()>().await,
|
|
}
|
|
};
|
|
tokio::select! {
|
|
biased; outcome = & mut fut => ForegroundWait::Done(outcome), _ =
|
|
parent_await_dropped => ForegroundWait::ParentGone, _ =
|
|
tokio::time::sleep(subagent_await_budget()) =>
|
|
ForegroundWait::Budget,
|
|
}
|
|
};
|
|
match first {
|
|
ForegroundWait::Done(outcome) => {
|
|
if matches!(outcome, SubagentWaitOutcome::Cancelled) {
|
|
parent_wait_guard.take();
|
|
}
|
|
outcome
|
|
}
|
|
ForegroundWait::ParentGone => {
|
|
parent_wait_guard.take();
|
|
tracing::info!(
|
|
subagent_id = % request.id,
|
|
"foreground subagent await abandoned by its parent turn; detaching child to background (child keeps running)",
|
|
);
|
|
if !cancel_token.is_cancelled() {
|
|
request.run_in_background = true;
|
|
coordinator.borrow_mut().mark_backgrounded(&request.id);
|
|
}
|
|
fut.await
|
|
}
|
|
ForegroundWait::Budget => {
|
|
tracing::info!(
|
|
subagent_id = % request.id, budget_ms = subagent_await_budget()
|
|
.as_millis() as u64,
|
|
"foreground subagent exceeded await budget; auto-backgrounding (child keeps running)",
|
|
);
|
|
if let Some(tx) = result_tx.take() {
|
|
let _ = tx
|
|
.send(SubagentResult {
|
|
backgrounded: true,
|
|
subagent_id: request.id.clone(),
|
|
child_session_id: child_session_id.0.to_string(),
|
|
..Default::default()
|
|
});
|
|
}
|
|
parent_wait_guard.take();
|
|
request.run_in_background = true;
|
|
coordinator.borrow_mut().mark_backgrounded(&request.id);
|
|
fut.await
|
|
}
|
|
}
|
|
} else {
|
|
fut.await
|
|
}
|
|
};
|
|
let duration_ms = start.elapsed().as_millis() as u64;
|
|
let mut result = match wait_outcome {
|
|
SubagentWaitOutcome::Cancelled => {
|
|
let (tool_calls, turns) = signals_snapshot_counts(&child_handle).await;
|
|
SubagentResult {
|
|
success: false,
|
|
cancelled: true,
|
|
error: Some("Subagent was cancelled".to_string()),
|
|
subagent_id: request.id.clone(),
|
|
child_session_id: child_session_id.0.to_string(),
|
|
tool_calls,
|
|
turns,
|
|
duration_ms,
|
|
worktree_path: worktree_path
|
|
.as_ref()
|
|
.map(|p| p.to_string_lossy().to_string()),
|
|
..Default::default()
|
|
}
|
|
}
|
|
SubagentWaitOutcome::TurnResult(turn_result) => {
|
|
let was_cancelled = cancel_token.is_cancelled();
|
|
let (tool_calls, turns) = match &*turn_result {
|
|
Ok(
|
|
Ok(
|
|
crate::session::commands::PromptTurnOk {
|
|
turn_snapshot: Some(snapshot),
|
|
..
|
|
},
|
|
),
|
|
) => (snapshot.current.tool_call_count, snapshot.current.turn_count),
|
|
_ => signals_snapshot_counts(&child_handle).await,
|
|
};
|
|
let final_text = child_handle
|
|
.chat_state_handle
|
|
.get_last_assistant_text()
|
|
.await
|
|
.unwrap_or_default();
|
|
let result_tokens = child_handle.chat_state_handle.get_total_tokens().await;
|
|
match *turn_result {
|
|
Ok(
|
|
Ok(
|
|
crate::session::commands::PromptTurnOk {
|
|
completion_kind: PromptCompletionKind::Cancelled {
|
|
category,
|
|
context,
|
|
},
|
|
..
|
|
},
|
|
),
|
|
) => {
|
|
let reason = cancellation_error_message(category, context.as_ref());
|
|
SubagentResult {
|
|
success: false,
|
|
cancelled: true,
|
|
error: Some(reason),
|
|
output: if final_text.is_empty() {
|
|
std::sync::Arc::from(
|
|
format!(
|
|
"Subagent '{}' ({}) was cancelled. {} tool calls, {} turns.",
|
|
request.description, request.subagent_type, tool_calls,
|
|
turns
|
|
),
|
|
)
|
|
} else {
|
|
std::sync::Arc::from(final_text)
|
|
},
|
|
subagent_id: request.id.clone(),
|
|
child_session_id: child_session_id.0.to_string(),
|
|
tool_calls,
|
|
turns,
|
|
duration_ms,
|
|
tokens_used: result_tokens,
|
|
worktree_path: worktree_path
|
|
.as_ref()
|
|
.map(|p| p.to_string_lossy().to_string()),
|
|
backgrounded: false,
|
|
}
|
|
}
|
|
Ok(
|
|
Ok(
|
|
crate::session::commands::PromptTurnOk {
|
|
completion_kind: PromptCompletionKind::MaxTurnsReached {
|
|
limit,
|
|
},
|
|
..
|
|
},
|
|
),
|
|
) => {
|
|
SubagentResult {
|
|
success: false,
|
|
cancelled: true,
|
|
error: Some(format!("max turns reached (limit: {limit})")),
|
|
output: if final_text.is_empty() {
|
|
std::sync::Arc::from(
|
|
format!(
|
|
"Subagent '{}' ({}) hit max-turns limit ({limit}). {} tool calls, {} turns.",
|
|
request.description, request.subagent_type, tool_calls,
|
|
turns
|
|
),
|
|
)
|
|
} else {
|
|
std::sync::Arc::from(final_text)
|
|
},
|
|
subagent_id: request.id.clone(),
|
|
child_session_id: child_session_id.0.to_string(),
|
|
tool_calls,
|
|
turns,
|
|
duration_ms,
|
|
tokens_used: result_tokens,
|
|
worktree_path: worktree_path
|
|
.as_ref()
|
|
.map(|p| p.to_string_lossy().to_string()),
|
|
backgrounded: false,
|
|
}
|
|
}
|
|
Ok(Ok(_)) => {
|
|
SubagentResult {
|
|
success: true,
|
|
output: if final_text.is_empty() {
|
|
std::sync::Arc::from(
|
|
format!(
|
|
"Subagent '{}' ({}) completed successfully. {} tool calls, {} turns.",
|
|
request.description, request.subagent_type, tool_calls,
|
|
turns
|
|
),
|
|
)
|
|
} else {
|
|
std::sync::Arc::from(final_text)
|
|
},
|
|
subagent_id: request.id.clone(),
|
|
child_session_id: child_session_id.0.to_string(),
|
|
tool_calls,
|
|
turns,
|
|
duration_ms,
|
|
tokens_used: result_tokens,
|
|
worktree_path: worktree_path
|
|
.as_ref()
|
|
.map(|p| p.to_string_lossy().to_string()),
|
|
..Default::default()
|
|
}
|
|
}
|
|
Ok(Err(e)) => {
|
|
SubagentResult {
|
|
success: false,
|
|
cancelled: was_cancelled,
|
|
error: Some(
|
|
if was_cancelled {
|
|
"Subagent was cancelled".to_string()
|
|
} else {
|
|
format!("Session error: {e}")
|
|
},
|
|
),
|
|
subagent_id: request.id.clone(),
|
|
child_session_id: child_session_id.0.to_string(),
|
|
tool_calls,
|
|
turns,
|
|
duration_ms,
|
|
worktree_path: worktree_path
|
|
.as_ref()
|
|
.map(|p| p.to_string_lossy().to_string()),
|
|
..Default::default()
|
|
}
|
|
}
|
|
Err(_) => {
|
|
SubagentResult {
|
|
success: false,
|
|
cancelled: was_cancelled,
|
|
error: Some(
|
|
if was_cancelled {
|
|
"Subagent was cancelled".to_string()
|
|
} else {
|
|
"Child session dropped unexpectedly".to_string()
|
|
},
|
|
),
|
|
subagent_id: request.id.clone(),
|
|
child_session_id: child_session_id.0.to_string(),
|
|
tool_calls,
|
|
turns,
|
|
duration_ms,
|
|
worktree_path: worktree_path
|
|
.as_ref()
|
|
.map(|p| p.to_string_lossy().to_string()),
|
|
..Default::default()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
update_subagent_meta_completed(&subagent_meta_dir, &result);
|
|
let final_status = result.status().to_string();
|
|
let snapshot_dispose_enabled = ctx.resolve_subagent_worktree_snapshot_enabled();
|
|
let telemetry_tokens = if result.tool_calls > 0 || result.success {
|
|
child_handle.chat_state_handle.get_total_tokens().await
|
|
} else {
|
|
0
|
|
};
|
|
let (subagent_usage_by_model, subagent_usage_incomplete) = match child_handle
|
|
.chat_state_handle
|
|
.try_get_session_usage()
|
|
.await
|
|
{
|
|
Ok(u) => (Some(u.by_model.into_iter().collect::<Vec<_>>()), u.incomplete),
|
|
Err(()) => (None, true),
|
|
};
|
|
let fold_acked = match subagent_usage_by_model {
|
|
None => false,
|
|
Some(ref by_model) if by_model.is_empty() && !subagent_usage_incomplete => true,
|
|
Some(by_model) => {
|
|
if let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref() {
|
|
let (respond_to, ack) = tokio::sync::oneshot::channel();
|
|
match cmd_tx
|
|
.send(crate::session::commands::SessionCommand::RecordSubagentUsage {
|
|
by_model,
|
|
parent_prompt_id: request.parent_prompt_id.clone(),
|
|
incomplete: subagent_usage_incomplete,
|
|
respond_to,
|
|
})
|
|
{
|
|
Ok(()) => ack.await.is_ok(),
|
|
Err(_) => false,
|
|
}
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
};
|
|
if !fold_acked {
|
|
tracing::warn!(
|
|
subagent_id = % request.id, parent_prompt_id = ? request.parent_prompt_id,
|
|
"subagent usage not applied; parent bill marked incomplete"
|
|
);
|
|
let sticky_prompt = request
|
|
.parent_prompt_id
|
|
.clone()
|
|
.or_else(|| coordinator.borrow().parent_prompt_id_for(&request.id));
|
|
if let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref() {
|
|
let (respond_to, ack) = tokio::sync::oneshot::channel();
|
|
if cmd_tx
|
|
.send(crate::session::commands::SessionCommand::MarkSubagentUsageNotApplied {
|
|
parent_prompt_id: sticky_prompt,
|
|
respond_to,
|
|
})
|
|
.is_ok()
|
|
{
|
|
let _ = ack.await;
|
|
}
|
|
} else if let Some(ref pid) = sticky_prompt {
|
|
coordinator.borrow_mut().mark_subagent_usage_not_applied(pid);
|
|
}
|
|
}
|
|
match (&ctx.parent_terminal_backend, &ctx.parent_notification_handle) {
|
|
(Some(parent_tb), Some(parent_notif_handle)) => {
|
|
if !request.surface_completion {
|
|
let reparented_task_ids: Vec<String> = parent_tb
|
|
.list_tasks()
|
|
.await
|
|
.into_iter()
|
|
.filter(|t| {
|
|
!t.completed
|
|
&& t.owner_session_id.as_deref()
|
|
== Some(&*child_session_id.0)
|
|
})
|
|
.map(|t| t.task_id)
|
|
.collect();
|
|
if !reparented_task_ids.is_empty()
|
|
&& let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref()
|
|
{
|
|
let _ = cmd_tx
|
|
.send(SessionCommand::RecordGoalTurnTaskIds {
|
|
task_ids: reparented_task_ids,
|
|
});
|
|
}
|
|
}
|
|
let parent_backend_weak = std::sync::Arc::downgrade(parent_tb);
|
|
parent_tb
|
|
.reparent_notifications(
|
|
&child_session_id.0,
|
|
&ctx.parent_session_id,
|
|
parent_notif_handle.clone(),
|
|
parent_backend_weak,
|
|
)
|
|
.await;
|
|
}
|
|
(Some(_), None) | (None, Some(_)) => {
|
|
tracing::warn!(
|
|
child_session_id = % child_session_id.0, parent_session_id = % ctx
|
|
.parent_session_id, has_terminal_backend = ctx.parent_terminal_backend
|
|
.is_some(), has_notification_handle = ctx.parent_notification_handle
|
|
.is_some(),
|
|
"skipping reparent_notifications: parent_terminal_backend and \
|
|
parent_notification_handle must both be Some"
|
|
);
|
|
}
|
|
(None, None) => {}
|
|
}
|
|
let _ = child_handle.cmd_tx.send(SessionCommand::Shutdown);
|
|
let mut disposed_snapshot_ref: Option<String> = None;
|
|
let mut worktree_removed = false;
|
|
if let Some(ref wt_path) = worktree_path {
|
|
if snapshot_dispose_enabled {
|
|
let ref_name = format!("refs/grok/subagents/{}", request.id);
|
|
let source_repo = resolve_subagent_source_repo(&ctx);
|
|
match crate::session::worktree::snapshot_subagent_worktree(
|
|
wt_path,
|
|
&source_repo,
|
|
&ref_name,
|
|
)
|
|
.await
|
|
{
|
|
Ok(snapshot_ref) => {
|
|
let persisted = update_subagent_meta_snapshot_ref(
|
|
&subagent_meta_dir,
|
|
&snapshot_ref,
|
|
&final_status,
|
|
);
|
|
if persisted {
|
|
disposed_snapshot_ref = Some(snapshot_ref);
|
|
match crate::session::worktree::remove_subagent_worktree(wt_path)
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
worktree_removed = true;
|
|
tracing::info!(
|
|
subagent_id = % request.id, worktree_path = % wt_path
|
|
.display(), "snapshotted and removed subagent worktree"
|
|
);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
subagent_id = % request.id, worktree_path = % wt_path
|
|
.display(), error = % e,
|
|
"snapshotted subagent worktree but removal failed; ref persisted for resume"
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
tracing::warn!(
|
|
subagent_id = % request.id, worktree_path = % wt_path
|
|
.display(),
|
|
"snapshot_ref not persisted; preserving worktree for resume"
|
|
);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
subagent_id = % request.id, worktree_path = % wt_path.display(),
|
|
error = % e,
|
|
"Failed to snapshot subagent worktree; preserving for review"
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
tracing::info!(
|
|
subagent_id = % request.id, worktree_path = % wt_path.display(),
|
|
"Worktree preserved for review"
|
|
);
|
|
}
|
|
}
|
|
if worktree_removed {
|
|
result.worktree_path = None;
|
|
}
|
|
let (block_waited, explicitly_killed) = {
|
|
let mut coord = coordinator.borrow_mut();
|
|
(
|
|
coord.block_wait_delivered_or_live(&request.id),
|
|
coord.is_explicitly_killed(&request.id),
|
|
)
|
|
};
|
|
let will_wake = should_auto_wake_subagent(
|
|
request.run_in_background,
|
|
result.cancelled,
|
|
ctx.auto_wake_enabled,
|
|
block_waited,
|
|
explicitly_killed,
|
|
ctx.goal_loop_active.load(std::sync::atomic::Ordering::Relaxed),
|
|
ctx.parent_cmd_tx.is_some(),
|
|
);
|
|
emit_subagent_notification(
|
|
gateway,
|
|
&ctx.parent_session_id,
|
|
SessionUpdate::SubagentFinished {
|
|
subagent_id: request.id.clone(),
|
|
child_session_id: result.child_session_id.clone(),
|
|
status: result.status().to_string(),
|
|
error: result.error.clone(),
|
|
tool_calls: result.tool_calls,
|
|
turns: result.turns,
|
|
duration_ms: result.duration_ms,
|
|
tokens_used: telemetry_tokens,
|
|
output: if result.success { Some(result.output.to_string()) } else { None },
|
|
will_wake,
|
|
},
|
|
ctx.parent_cmd_tx.as_ref(),
|
|
);
|
|
coordinator
|
|
.borrow_mut()
|
|
.move_to_completed(
|
|
&request.id,
|
|
request.description.clone(),
|
|
request.subagent_type.clone(),
|
|
result.clone(),
|
|
);
|
|
if let Some(snapshot_ref) = disposed_snapshot_ref {
|
|
coordinator.borrow_mut().set_completed_snapshot_ref(&request.id, snapshot_ref);
|
|
}
|
|
if will_wake {
|
|
inject_subagent_completed_prompt(
|
|
&request.id,
|
|
&result,
|
|
&request,
|
|
&ctx.auto_wake_delivered,
|
|
ctx.parent_cmd_tx.as_ref(),
|
|
&ctx.task_output_tool_name,
|
|
);
|
|
}
|
|
if let Some(tx) = result_tx.take() {
|
|
let _ = tx.send(result);
|
|
}
|
|
}
|