One authority answers 'which credential may ride this request': credential_class / manager_for / credential_for / bearer_resolver_for, keyed by (platform, base_url). SessionCredential is an opaque type with no production constructor, so a new call site cannot re-introduce the session-bearer leak. Platform-scoped tests extended across all bearer channels (session, aux, summary, subagent override). Verified: cargo check --workspace --all-targets clean; kigi-shell and kigi-tui suites green (6611+ tests).
1919 lines
84 KiB
Rust
1919 lines
84 KiB
Rust
//! Session bring-up concern for `acp_session`: `spawn_session_actor`, the
|
|
//! per-session OS thread (`SessionThread` / `spawn_session_on_thread`), and
|
|
//! the MCP auto-restart wiring (`SessionRestartActions`).
|
|
#![allow(clippy::items_after_test_module)]
|
|
use super::*;
|
|
use crate::agent::models_fetch::DEFAULT_CONTEXT_WINDOW;
|
|
/// Partition CLI `--allow` rules under the pin: blanket catch-all allows
|
|
/// (`Allow(Any)` `*` / `**`, plus bare/match-all Bash/MCP/WebFetch grants — see
|
|
/// `resolution::is_catchall_allow`) substitute for the blocked `--yolo`, so drop them when
|
|
/// `policy_block` is set; keep everything else (and everything without a pin).
|
|
/// Pure (no I/O) so the wiring is unit-testable; the caller surfaces `dropped`.
|
|
fn drop_cli_catchall_allows(
|
|
rules: Vec<kigi_workspace::permission::types::PermissionRule>,
|
|
policy_block: Option<&'static str>,
|
|
) -> (
|
|
Vec<kigi_workspace::permission::types::PermissionRule>,
|
|
Vec<kigi_workspace::permission::types::PermissionRule>,
|
|
) {
|
|
if policy_block.is_none() {
|
|
return (rules, Vec::new());
|
|
}
|
|
let mut kept = Vec::with_capacity(rules.len());
|
|
let mut dropped = Vec::new();
|
|
for rule in rules {
|
|
if kigi_workspace::permission::resolution::is_catchall_allow(&rule) {
|
|
dropped.push(rule);
|
|
} else {
|
|
kept.push(rule);
|
|
}
|
|
}
|
|
(kept, dropped)
|
|
}
|
|
#[cfg(test)]
|
|
mod cli_catchall_drop_tests {
|
|
use super::drop_cli_catchall_allows;
|
|
use kigi_workspace::permission::resolution::YOLO_PIN_REASON_REQUIREMENTS;
|
|
use kigi_workspace::permission::rules::parse_permission_rule;
|
|
use kigi_workspace::permission::types::{PermissionRule, RuleAction, ToolFilter};
|
|
fn allow(rule: &str) -> PermissionRule {
|
|
parse_permission_rule(rule, RuleAction::Allow).expect("rule parses")
|
|
}
|
|
/// Under the pin, CLI catch-all `--allow` rules (`*`, `**`) are dropped while
|
|
/// a scoped rule (`Bash(touch *)`) survives.
|
|
#[test]
|
|
fn pin_drops_cli_catchalls_keeps_scoped() {
|
|
let rules = vec![allow("*"), allow("Bash(touch *)"), allow("**")];
|
|
let (kept, dropped) = drop_cli_catchall_allows(rules, Some(YOLO_PIN_REASON_REQUIREMENTS));
|
|
assert_eq!(kept.len(), 1, "only the scoped Bash rule survives");
|
|
assert_eq!(kept[0].tool, ToolFilter::Bash);
|
|
assert_eq!(dropped.len(), 2, "both catch-alls are dropped");
|
|
}
|
|
/// Without the pin nothing is dropped, even catch-alls.
|
|
#[test]
|
|
fn no_pin_keeps_everything() {
|
|
let rules = vec![allow("*"), allow("Bash(touch *)"), allow("**")];
|
|
let (kept, dropped) = drop_cli_catchall_allows(rules, None);
|
|
assert_eq!(kept.len(), 3);
|
|
assert!(dropped.is_empty());
|
|
}
|
|
/// FIX 2: a bare `--allow Bash` and a `?*` Bash pattern are `--yolo`
|
|
/// substitutes on the freeform-execution dimension, so the pin drops them
|
|
/// while a scoped `Bash(git *)` survives.
|
|
#[test]
|
|
fn pin_drops_cli_bare_and_prefix_bash_keeps_scoped() {
|
|
let rules = vec![allow("Bash"), allow("Bash(?*)"), allow("Bash(git *)")];
|
|
let (kept, dropped) = drop_cli_catchall_allows(rules, Some(YOLO_PIN_REASON_REQUIREMENTS));
|
|
assert_eq!(kept.len(), 1, "only the scoped Bash rule survives");
|
|
assert_eq!(kept[0].pattern.as_deref(), Some("git *"));
|
|
assert_eq!(dropped.len(), 2, "bare Bash and ?* are dropped");
|
|
let rules = vec![allow("Bash"), allow("Bash(?*)"), allow("Bash(git *)")];
|
|
let (kept, dropped) = drop_cli_catchall_allows(rules, None);
|
|
assert_eq!(kept.len(), 3);
|
|
assert!(dropped.is_empty());
|
|
}
|
|
}
|
|
/// Spawns a session actor and returns the session handle plus a receiver for permission events.
|
|
///
|
|
/// The permission events receiver should be used to collect telemetry about permission
|
|
/// decisions (YOLO mode, user accept/reject, etc.) for upload to GCS.
|
|
#[allow(clippy::too_many_arguments)]
|
|
#[tracing::instrument(
|
|
name = "session.spawn",
|
|
skip_all,
|
|
fields(
|
|
session_id = %session_info.id.0,
|
|
client_type = ?client_type,
|
|
start_type = if
|
|
initial_prompt_texts.is_empty(){"new"}else{"resumed"},
|
|
),
|
|
)]
|
|
pub(crate) async fn spawn_session_actor(
|
|
session_info: SessionInfo,
|
|
gateway: GatewaySender,
|
|
sampling_config: SamplingConfig,
|
|
credentials: kigi_chat_state::Credentials,
|
|
auth_method_id: crate::agent::auth_method::SharedAuthMethodId,
|
|
auth_manager: Option<Arc<AuthManager>>,
|
|
attribution_callback: Option<kigi_sampler::SharedAttributionCallback>,
|
|
mut tool_context: ToolContext,
|
|
mcp_servers: Vec<acp::McpServer>,
|
|
initial_client_mcp_servers: Vec<acp::McpServer>,
|
|
mcp_meta_config_map: McpMetaConfigMap,
|
|
parent_mcp_pool: Option<crate::session::mcp_servers::SharedMcpPool>,
|
|
acp_mcp_servers: Vec<crate::session::mcp_servers::AcpServerEntry>,
|
|
support_permission: bool,
|
|
auto_update: Option<bool>,
|
|
persistence: PersistenceHandle,
|
|
mut conversation: Vec<ConversationItem>,
|
|
rewind_points_path: Option<std::path::PathBuf>,
|
|
initial_last_compaction: Option<usize>,
|
|
initial_prompt_texts: Vec<String>,
|
|
fs_notify_config: Option<ClientFsConfig>,
|
|
initial_total_tokens: u64,
|
|
mut startup_hints: StartupHints,
|
|
client_type: ClientType,
|
|
auto_compact_threshold_percent: u8,
|
|
system_prompt_label: String,
|
|
compaction_mode: kigi_chat_state::CompactionMode,
|
|
compaction_verbatim_input: bool,
|
|
two_pass_enabled: bool,
|
|
buffering_settings: Option<BufferingSettings>,
|
|
origin_client: Option<crate::http::OriginClientInfo>,
|
|
codebase_indexes: std::sync::Arc<parking_lot::Mutex<CodebaseIndexManager>>,
|
|
code_nav_enabled: bool,
|
|
fs_watch_caps: fs_watch::FsWatchCapabilities,
|
|
feedback_base_url: Option<String>,
|
|
client_terminal_capable: bool,
|
|
client_fs_capable: bool,
|
|
gateway_enabled: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
|
agent_definition: AgentDefinition,
|
|
session_default_agent_profile: Option<String>,
|
|
skills_config: SkillsConfig,
|
|
preloaded_skills: Option<Vec<kigi_tools::implementations::skills::types::SkillInfo>>,
|
|
compat: CompatConfig,
|
|
incremental_bash_output: bool,
|
|
persisted_signals: Option<crate::session::signals::SessionSignals>,
|
|
persisted_plan_mode: Option<crate::session::plan_mode::PlanModeSnapshot>,
|
|
persisted_goal_mode: Option<crate::session::goal_tracker::GoalOrchestration>,
|
|
persisted_graph_mode: Option<crate::session::graph_tracker::GraphOrchestration>,
|
|
persisted_announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
|
|
memory_config: Option<crate::config::MemoryConfig>,
|
|
feedback_flags: crate::session::feedback_manager::FeedbackFlags,
|
|
session_model_id: acp::ModelId,
|
|
session_yolo_mode: bool,
|
|
session_auto_mode: bool,
|
|
session_client_identifier: Option<String>,
|
|
inference_idle_timeout_secs: u64,
|
|
max_retries: Option<u32>,
|
|
web_search_config: kigi_tools::implementations::WebSearchConfig,
|
|
web_fetch_config: kigi_tools::implementations::kigi::web_fetch::WebFetchConfig,
|
|
app_builder_deployer_config: kigi_tools::implementations::kigi::deploy_app::AppBuilderDeployerConfig,
|
|
write_file_enabled: bool,
|
|
goal_enabled: bool,
|
|
graph_enabled: bool,
|
|
subagents_enabled: bool,
|
|
ask_user_question_enabled: bool,
|
|
client_hooks: crate::extensions::hooks::ClientHooks,
|
|
prompt_display_cwd: Option<String>,
|
|
subagent_toggle: std::collections::HashMap<String, bool>,
|
|
persona_summaries: Vec<String>,
|
|
prompt_audience: kigi_agent::prompt::context::PromptAudience,
|
|
role_instructions: Option<String>,
|
|
persona_instructions: Option<String>,
|
|
disable_web_search: bool,
|
|
backend_tools_enabled: bool,
|
|
respect_gitignore: bool,
|
|
path_not_found_hints: bool,
|
|
tool_params_json: crate::session::agent_rebuild::ResolvedToolParamsJson,
|
|
plugin_registry: Option<std::sync::Arc<kigi_agent::plugins::PluginRegistry>>,
|
|
plugin_registry_handle: Option<kigi_agent::plugins::SharedPluginRegistryHandle>,
|
|
models_manager: crate::agent::models::ModelsManager,
|
|
inherited_permission_handle: Option<kigi_workspace::permission::PermissionHandle>,
|
|
api_key_provider: Option<kigi_tools::types::SharedApiKeyProvider>,
|
|
image_description_model: String,
|
|
hook_registry_override: Option<std::sync::Arc<kigi_hooks::discovery::HookRegistry>>,
|
|
workspace_ops: kigi_workspace::WorkspaceOps,
|
|
cli_permission_rules: Vec<kigi_workspace::permission::types::PermissionRule>,
|
|
todo_gate: bool,
|
|
remote_settings: Option<crate::util::config::RemoteSettings>,
|
|
laziness_debug_log: Option<std::path::PathBuf>,
|
|
parent_terminal_backend: Option<
|
|
std::sync::Arc<dyn kigi_tools::computer::types::TerminalBackend>,
|
|
>,
|
|
parent_scheduler_handle: Option<
|
|
kigi_tools::implementations::kigi::scheduler::types::SchedulerHandle,
|
|
>,
|
|
max_turns: Option<usize>,
|
|
forked_tool_override: Option<Vec<ToolSpec>>,
|
|
) -> Result<(SessionHandle, String, tokio::sync::oneshot::Receiver<()>), kigi_agent::AgentBuildError>
|
|
{
|
|
if max_turns == Some(0) {
|
|
return Err(kigi_agent::AgentBuildError::InvalidConfig(
|
|
"max_turns must be greater than 0".to_string(),
|
|
));
|
|
}
|
|
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
|
|
tracing::info!(
|
|
"Session '{}' created with {} MCP servers",
|
|
session_info.id.0,
|
|
mcp_servers.len()
|
|
);
|
|
let _ = support_permission;
|
|
let owns_permission_manager = inherited_permission_handle.is_none();
|
|
let (permissions, deny_read_globs) = if let Some(handle) = inherited_permission_handle {
|
|
let deny_read_globs = handle.deny_read_globs();
|
|
(handle, deny_read_globs)
|
|
} else {
|
|
let web_fetch_allowed_domains = match &web_fetch_config {
|
|
WebFetchConfig::Enabled { params } => params.allowed_domains(),
|
|
WebFetchConfig::Disabled => vec![],
|
|
};
|
|
let mut permission_config =
|
|
kigi_workspace::permission::resolution::resolve_permission_config_with_fallback(
|
|
tool_context.cwd.as_path(),
|
|
)
|
|
.await;
|
|
let yolo_pin = kigi_workspace::permission::resolution::yolo_disabled_by_policy();
|
|
let (cli_permission_rules, dropped_catchalls) =
|
|
drop_cli_catchall_allows(cli_permission_rules, yolo_pin);
|
|
if let Some(reason) = yolo_pin
|
|
&& !dropped_catchalls.is_empty()
|
|
{
|
|
tracing::warn!(
|
|
reason,
|
|
dropped = dropped_catchalls.len(),
|
|
"CLI --allow catch-all ignored: always-approve disabled by managed policy"
|
|
);
|
|
if startup_hints.non_interactive {
|
|
eprintln!("kigi: --allow catch-all ignored: {reason}");
|
|
}
|
|
}
|
|
if !cli_permission_rules.is_empty() {
|
|
match &mut permission_config {
|
|
Some(config) => {
|
|
let mut merged = cli_permission_rules;
|
|
merged.append(&mut config.rules);
|
|
config.rules = merged;
|
|
}
|
|
None => {
|
|
permission_config =
|
|
Some(kigi_workspace::permission::types::PermissionConfig::new(
|
|
cli_permission_rules,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
let deny_read_globs = permission_config
|
|
.as_ref()
|
|
.map(kigi_workspace::permission::resolution::deny_read_globs_from_config)
|
|
.unwrap_or_default();
|
|
let (permissions, _permission_events_rx) =
|
|
kigi_workspace::permission::spawn_permission_manager(
|
|
session_info.id.clone(),
|
|
gateway.clone(),
|
|
tool_context.cwd.clone(),
|
|
client_type,
|
|
permission_config,
|
|
deny_read_globs.clone(),
|
|
web_fetch_allowed_domains,
|
|
session_yolo_mode,
|
|
session_client_identifier.clone(),
|
|
crate::util::config::remember_tool_approvals_from_disk(),
|
|
);
|
|
if crate::util::config::auto_mode_session_active(
|
|
crate::util::config::auto_permission_mode_enabled_from_disk(),
|
|
session_auto_mode,
|
|
session_yolo_mode,
|
|
) {
|
|
permissions.set_auto_mode(true);
|
|
let turns = build_classifier_turns(&conversation, CLASSIFIER_SPAWN_SEED_TURNS);
|
|
if !turns.is_empty() {
|
|
permissions.set_classifier_transcript(turns);
|
|
}
|
|
}
|
|
(permissions, deny_read_globs)
|
|
};
|
|
let initial_prompt_index = conversation
|
|
.iter()
|
|
.filter(|item| matches!(item, ConversationItem::User(_)))
|
|
.count();
|
|
let initial_conversation_len = conversation.len();
|
|
let initial_user_count = initial_prompt_index.saturating_sub(1) as u32;
|
|
let initial_assistant_count = conversation
|
|
.iter()
|
|
.filter(|item| matches!(item, ConversationItem::Assistant(_)))
|
|
.count() as u32;
|
|
let (initial_tool_call_count, initial_tools_used, initial_models_used) =
|
|
if persisted_signals.is_none() {
|
|
let mut tool_call_count: u32 = 0;
|
|
let mut tools_used = std::collections::HashSet::new();
|
|
let mut models_used = std::collections::HashSet::new();
|
|
for item in &conversation {
|
|
if let ConversationItem::Assistant(assistant) = item {
|
|
tool_call_count += assistant.tool_calls.len() as u32;
|
|
for tc in &assistant.tool_calls {
|
|
tools_used.insert(tc.name.clone());
|
|
}
|
|
if let Some(model_id) = &assistant.model_id {
|
|
models_used.insert(model_id.clone());
|
|
}
|
|
}
|
|
}
|
|
(
|
|
tool_call_count,
|
|
tools_used.into_iter().collect::<Vec<_>>(),
|
|
models_used.into_iter().collect::<Vec<_>>(),
|
|
)
|
|
} else {
|
|
(0, Vec::new(), Vec::new())
|
|
};
|
|
let primary_model_id = sampling_config.model.clone();
|
|
let web_search_config = if disable_web_search {
|
|
kigi_tools::implementations::WebSearchConfig::Disabled
|
|
} else {
|
|
web_search_config
|
|
};
|
|
let embed_base_url = sampling_config.base_url.clone();
|
|
let embed_api_key = sampling_config.api_key.clone();
|
|
let session_pruning_config: crate::config::PruningConfig = memory_config.as_ref().map_or_else(
|
|
|| crate::config::PruningConfig {
|
|
enabled: false,
|
|
..Default::default()
|
|
},
|
|
|mc| mc.pruning.clone(),
|
|
);
|
|
let context_window_override = std::env::var("KIGI_DEBUG_CONTEXT_WINDOW")
|
|
.ok()
|
|
.and_then(|v| v.parse::<u64>().ok())
|
|
.and_then(std::num::NonZeroU64::new);
|
|
let baseline_context_window = std::num::NonZeroU64::new(sampling_config.context_window)
|
|
.unwrap_or_else(|| {
|
|
std::num::NonZeroU64::new(DEFAULT_CONTEXT_WINDOW)
|
|
.expect("DEFAULT_CONTEXT_WINDOW is non-zero")
|
|
});
|
|
if let Some(cw) = context_window_override {
|
|
tracing::warn!(
|
|
override_context_window = cw.get(),
|
|
original_context_window = baseline_context_window.get(),
|
|
"KIGI_DEBUG_CONTEXT_WINDOW override active"
|
|
);
|
|
}
|
|
let chat_state_sampling_config = kigi_sampling_types::SamplingConfig {
|
|
base_url: sampling_config.base_url.clone(),
|
|
model: sampling_config.model.clone(),
|
|
max_completion_tokens: sampling_config.max_completion_tokens,
|
|
temperature: sampling_config.temperature,
|
|
top_p: sampling_config.top_p,
|
|
api_backend: sampling_config.api_backend.clone(),
|
|
chat_compat: sampling_config.chat_compat,
|
|
extra_headers: sampling_config.extra_headers.clone(),
|
|
context_window: context_window_override.unwrap_or(baseline_context_window),
|
|
reasoning_effort: sampling_config.reasoning_effort,
|
|
stream_tool_calls: Some(sampling_config.stream_tool_calls),
|
|
};
|
|
let actor_pruning_config = kigi_chat_state::PruningConfig {
|
|
enabled: session_pruning_config.enabled,
|
|
keep_last_n_turns: session_pruning_config.keep_last_n_turns,
|
|
soft_trim_threshold: session_pruning_config.soft_trim_threshold,
|
|
soft_trim_head: session_pruning_config.soft_trim_head,
|
|
soft_trim_tail: session_pruning_config.soft_trim_tail,
|
|
hard_clear_age_turns: session_pruning_config.hard_clear_age_turns,
|
|
};
|
|
let (chat_state_event_tx, chat_state_event_rx) = mpsc::unbounded_channel();
|
|
let chat_state_handle = kigi_chat_state::ChatStateActor::spawn_with_pruning(
|
|
conversation.clone(),
|
|
chat_state_sampling_config,
|
|
actor_pruning_config,
|
|
Box::new(super::chat_persistence::ChannelChatPersistence::new(
|
|
persistence.tx.clone(),
|
|
)),
|
|
chat_state_event_tx,
|
|
tokio_util::sync::CancellationToken::new(),
|
|
);
|
|
if (!initial_prompt_texts.is_empty()
|
|
|| initial_total_tokens > 0
|
|
|| initial_last_compaction.is_some())
|
|
&& let Some(mut snap) = chat_state_handle.snapshot().await
|
|
{
|
|
snap.prompt_index = initial_prompt_texts.len();
|
|
snap.prompt_texts = initial_prompt_texts;
|
|
if initial_total_tokens > 0 {
|
|
snap.total_tokens = initial_total_tokens;
|
|
}
|
|
snap.last_compaction_prompt_index = initial_last_compaction;
|
|
chat_state_handle.restore_snapshot(snap);
|
|
}
|
|
chat_state_handle.update_credentials(credentials);
|
|
let state = TokioMutex::new(State {
|
|
running_task: None,
|
|
pending_inputs: VecDeque::new(),
|
|
pending_notifications: Vec::new(),
|
|
notifications_suppressed: false,
|
|
rewindable: false,
|
|
nudges_used_this_session: 0,
|
|
});
|
|
let mcp_strategy = match std::env::var("MCP_INIT_STRATEGY") {
|
|
Ok(v) if !v.trim().is_empty() => McpInitStrategy::from(v),
|
|
_ if startup_hints.non_interactive => McpInitStrategy::Blocking,
|
|
_ => McpInitStrategy::Progressive,
|
|
};
|
|
let file_state_tracker = Arc::new(match rewind_points_path {
|
|
Some(path) => FileStateTracker::with_lazy_source(path),
|
|
None => FileStateTracker::new(),
|
|
});
|
|
let file_state_handle = FileStateHandle::new(file_state_tracker.clone());
|
|
let auto_wake_delivered =
|
|
kigi_tools::reminders::task_completion::AutoWakeDeliveredIds::default();
|
|
tool_context.auto_wake_delivered = Some(auto_wake_delivered.clone());
|
|
let mut tool_context = tool_context.with_file_state_handle(file_state_handle);
|
|
let index_root_for_session =
|
|
kigi_workspace::session::git::find_git_root_from_path(tool_context.cwd.as_path())
|
|
.unwrap_or_else(|_| tool_context.cwd.to_path_buf());
|
|
let chat_state_handle_for_handle = chat_state_handle.clone();
|
|
let hunk_tracker_handle_for_bridge = tool_context.hunk_tracker_handle.clone();
|
|
let hunk_tracker_handle = tool_context.hunk_tracker_handle.clone();
|
|
let prompt_index_for_bridge = tool_context.prompt_index.clone();
|
|
let plan_mode = {
|
|
let session_dir = crate::session::persistence::session_dir(&session_info);
|
|
let tracker = if let Some(snapshot) = persisted_plan_mode {
|
|
crate::session::plan_mode::PlanModeTracker::from_snapshot(session_dir, snapshot)
|
|
} else {
|
|
crate::session::plan_mode::PlanModeTracker::new(session_dir)
|
|
};
|
|
Arc::new(parking_lot::Mutex::new(tracker))
|
|
};
|
|
let goal_tracker = {
|
|
let session_dir = crate::session::persistence::session_dir(&session_info);
|
|
let tracker = if let Some(snapshot) = persisted_goal_mode {
|
|
crate::session::goal_tracker::GoalTracker::from_snapshot(session_dir, snapshot)
|
|
} else {
|
|
crate::session::goal_tracker::GoalTracker::new(session_dir)
|
|
};
|
|
Arc::new(parking_lot::Mutex::new(tracker))
|
|
};
|
|
let graph_project_dir =
|
|
crate::session::graph_project::project_graph_dir(tool_context.cwd.as_path());
|
|
let graph_tracker = {
|
|
let session_dir = crate::session::persistence::session_dir(&session_info);
|
|
let tracker = if let Some(snapshot) = persisted_graph_mode {
|
|
crate::session::graph_tracker::GraphTracker::from_snapshot(session_dir, snapshot)
|
|
} else {
|
|
crate::session::graph_tracker::GraphTracker::new(session_dir)
|
|
};
|
|
Arc::new(parking_lot::Mutex::new(tracker))
|
|
};
|
|
let current_prompt_mode = Arc::new(parking_lot::Mutex::new(PromptMode::Agent));
|
|
let turn_prompt_mode = Arc::new(parking_lot::Mutex::new(PromptMode::Agent));
|
|
let task_output_tool_name = Arc::new(std::sync::OnceLock::new());
|
|
let read_tool_name = Arc::new(std::sync::OnceLock::new());
|
|
let queue_exit_reminder_on_approved_exit = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
let tools_notification_handle = crate::tools::notification_bridge::spawn_notification_bridge(
|
|
crate::tools::notification_bridge::NotificationBridgeConfig {
|
|
gateway: gateway.clone(),
|
|
session_id: session_info.id.clone(),
|
|
hunk_tracker_handle: hunk_tracker_handle_for_bridge.clone(),
|
|
file_state_tracker: file_state_tracker.clone(),
|
|
prompt_index: prompt_index_for_bridge,
|
|
cwd: tool_context.cwd.as_path().to_path_buf(),
|
|
gateway_enabled: gateway_enabled.clone(),
|
|
persistence_tx: persistence.tx.clone(),
|
|
incremental_bash_output,
|
|
plan_mode: plan_mode.clone(),
|
|
current_prompt_mode: current_prompt_mode.clone(),
|
|
turn_prompt_mode: turn_prompt_mode.clone(),
|
|
session_cmd_tx: cmd_tx.clone(),
|
|
auto_wake_delivered: auto_wake_delivered.clone(),
|
|
task_output_tool_name: task_output_tool_name.clone(),
|
|
read_tool_name: read_tool_name.clone(),
|
|
auto_wake_enabled: tool_context.auto_wake_enabled,
|
|
queue_exit_reminder_on_approved_exit: queue_exit_reminder_on_approved_exit.clone(),
|
|
goal_loop_active: tool_context.goal_loop_active_gate.clone(),
|
|
},
|
|
);
|
|
let tool_context_for_handle = tool_context.clone();
|
|
let resolve_search_shadows = || {
|
|
let user_cfg = crate::config::load_effective_config().ok();
|
|
let requirements = crate::config::load_merged_requirements();
|
|
let (find_bfs, grep_ugrep) = crate::util::config::resolve_search_tools_enabled(
|
|
requirements.as_ref(),
|
|
user_cfg.as_ref(),
|
|
None,
|
|
);
|
|
kigi_tools::computer::local::SearchShadowConfig {
|
|
find_bfs,
|
|
grep_ugrep,
|
|
}
|
|
};
|
|
let persistent_local_shell = crate::util::config::resolve_persistent_local_shell(
|
|
remote_settings
|
|
.as_ref()
|
|
.and_then(|r| r.persistent_local_shell),
|
|
);
|
|
let terminal_backend_kind = select_terminal_backend_kind(
|
|
startup_hints.is_subagent,
|
|
parent_terminal_backend.is_some(),
|
|
client_terminal_capable,
|
|
tool_context.gateway.is_some(),
|
|
persistent_local_shell,
|
|
);
|
|
let terminal_backend: std::sync::Arc<dyn kigi_tools::computer::types::TerminalBackend> =
|
|
match terminal_backend_kind {
|
|
TerminalBackendKind::ReuseParent => parent_terminal_backend
|
|
.expect("ReuseParent is only selected when a parent backend is present"),
|
|
TerminalBackendKind::AcpClient => {
|
|
std::sync::Arc::new(crate::terminal::AcpTerminalAdapter::new(
|
|
tool_context.gateway.clone().unwrap(),
|
|
tool_context.session_id.clone().unwrap(),
|
|
))
|
|
as std::sync::Arc<dyn kigi_tools::computer::types::TerminalBackend>
|
|
}
|
|
TerminalBackendKind::LocalPersistent => std::sync::Arc::new(
|
|
LocalTerminalBackend::new_local_with_persistent_shell(resolve_search_shadows()),
|
|
),
|
|
TerminalBackendKind::LocalNonPersistent => {
|
|
std::sync::Arc::new(LocalTerminalBackend::new_local(resolve_search_shadows()))
|
|
}
|
|
};
|
|
if terminal_backend_kind == TerminalBackendKind::LocalPersistent {
|
|
terminal_backend
|
|
.warm_persistent_shell(tool_context.cwd.as_path())
|
|
.await;
|
|
}
|
|
let fs_backend: std::sync::Arc<dyn kigi_tools::computer::types::AsyncFileSystem> =
|
|
if client_fs_capable && tool_context.gateway.is_some() {
|
|
std::sync::Arc::new(kigi_workspace::file_system::AcpFsAdapter::new(
|
|
tool_context.gateway.clone().unwrap(),
|
|
tool_context.session_id.clone().unwrap(),
|
|
))
|
|
} else {
|
|
std::sync::Arc::new(kigi_tools::computer::local::LocalFs)
|
|
};
|
|
let bridge_state_path =
|
|
crate::session::persistence::session_dir(&session_info).join("tool_state.json");
|
|
let initial_agent_type = Some(agent_definition.name.clone());
|
|
let compaction_policy = kigi_agent::CompactionPolicy {
|
|
auto_compact_threshold_percent: auto_compact_threshold_percent as u32,
|
|
compact_model: None,
|
|
memory_flush_enabled: memory_config.as_ref().is_some_and(|mc| mc.flush.enabled),
|
|
wall_clock_budget_secs: crate::util::config::resolve_compaction_wall_clock_budget_secs(
|
|
remote_settings
|
|
.as_ref()
|
|
.and_then(|r| r.compaction_wall_clock_budget_secs),
|
|
),
|
|
two_pass_enabled,
|
|
};
|
|
let reminder_policy = resolve_reminder_policy(remote_settings.as_ref(), todo_gate);
|
|
let (user_question_tx, user_question_rx) = tokio::sync::mpsc::unbounded_channel::<
|
|
kigi_tools::implementations::kigi::ask_user_question::types::UserQuestionRequest,
|
|
>();
|
|
let attribution_callback_for_spec = auth_manager.as_ref().map(|am| {
|
|
crate::auth::attribution::ShellAttribution::new_tool_callback(
|
|
am.clone(),
|
|
Some(session_info.id.0.to_string()),
|
|
)
|
|
});
|
|
let memory_storage_for_session = memory_config.as_ref().filter(|mc| mc.enabled).map(|mc| {
|
|
if mc.flat_memory_root
|
|
&& let Some(ref root) = mc.root_dir_override
|
|
{
|
|
return crate::session::memory::MemoryStorage::new_flat(
|
|
tool_context.cwd.as_path(),
|
|
root,
|
|
);
|
|
}
|
|
crate::session::memory::MemoryStorage::new(
|
|
tool_context.cwd.as_path(),
|
|
mc.root_dir_override.as_deref(),
|
|
)
|
|
});
|
|
let memory_initial_injection_config = memory_config
|
|
.as_ref()
|
|
.map_or_else(Default::default, |mc| mc.initial_injection.clone());
|
|
let mut memory_backend_params_for_session: Option<crate::session::memory::MemoryBackendParams> =
|
|
None;
|
|
let mut memory_search_counter: Option<std::sync::Arc<std::sync::atomic::AtomicU64>> = None;
|
|
let memory_backend_for_spec: Option<
|
|
std::sync::Arc<dyn kigi_tools::types::memory_backend::MemoryBackend>,
|
|
> = if let Some(ref storage) = memory_storage_for_session {
|
|
if let Err(e) = storage.ensure_initialized() {
|
|
tracing::warn!(
|
|
target : kigi_log::memory_log::TARGET, error = % e,
|
|
"MEMORY_INIT: ensure_initialized failed, continuing without template files"
|
|
);
|
|
}
|
|
{
|
|
let gc_storage = storage.clone();
|
|
let gc_max_age = memory_config.as_ref().map_or(30, |mc| mc.gc.max_age_days);
|
|
tokio::task::spawn_blocking(move || match gc_storage.gc(gc_max_age) {
|
|
Ok(removed) if removed > 0 => {
|
|
tracing::info!(
|
|
target : kigi_log::memory_log::TARGET, removed,
|
|
"MEMORY_GC: cleaned orphaned workspace directories"
|
|
);
|
|
}
|
|
Err(e) => {
|
|
tracing::debug!(
|
|
target : kigi_log::memory_log::TARGET, error = % e,
|
|
"MEMORY_GC: failed"
|
|
);
|
|
}
|
|
_ => {}
|
|
});
|
|
}
|
|
let watcher_config = memory_config
|
|
.as_ref()
|
|
.map(|mc| &mc.watcher)
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
let watcher = if watcher_config.enabled {
|
|
crate::session::memory::watcher::MemoryFileWatcher::start(storage.global_dir())
|
|
.map(std::sync::Arc::new)
|
|
} else {
|
|
None
|
|
};
|
|
let params = crate::session::memory::MemoryBackendParams {
|
|
session_id: session_info.id.to_string(),
|
|
embed_config: memory_config.as_ref().map(|mc| mc.embedding.clone()),
|
|
embed_base_url: embed_base_url.clone(),
|
|
embed_api_key: embed_api_key.clone(),
|
|
search_config: memory_config
|
|
.as_ref()
|
|
.map_or_else(Default::default, |mc| mc.search.clone()),
|
|
watcher,
|
|
stale_claim_secs: watcher_config.stale_claim_secs,
|
|
search_source: "tool",
|
|
api_key_provider: api_key_provider.clone(),
|
|
auth_credentials: auth_manager.as_ref().map(|am| {
|
|
std::sync::Arc::new(
|
|
crate::auth::credential_provider::ShellAuthCredentialProvider::new(
|
|
am.clone(),
|
|
None,
|
|
None,
|
|
),
|
|
) as std::sync::Arc<dyn kigi_auth::AuthCredentialProvider>
|
|
}),
|
|
};
|
|
let backend = crate::session::memory::MemoryBackendImpl::from_session_params(
|
|
storage.clone(),
|
|
¶ms,
|
|
);
|
|
memory_search_counter = Some(backend.search_counter.clone());
|
|
let watcher_started = params.watcher.is_some();
|
|
let backend: std::sync::Arc<dyn kigi_tools::types::memory_backend::MemoryBackend> =
|
|
std::sync::Arc::new(backend);
|
|
memory_backend_params_for_session = Some(params);
|
|
if watcher_config.enabled && !watcher_started {
|
|
tracing::warn!(
|
|
target : kigi_log::memory_log::TARGET,
|
|
"MEMORY_INIT: watcher was configured but failed to start \
|
|
(directory may not exist or OS watcher unavailable)"
|
|
);
|
|
}
|
|
tracing::info!(
|
|
target : kigi_log::memory_log::TARGET, workspace = % storage
|
|
.workspace_dir().display(), global = % storage.global_dir().display(),
|
|
watcher_config_enabled = watcher_config.enabled, watcher_started,
|
|
"MEMORY_INIT: storage + backend created"
|
|
);
|
|
let mc = memory_config.as_ref();
|
|
let total_chunks = storage.total_chunk_count();
|
|
Some(backend)
|
|
} else {
|
|
tracing::debug!(
|
|
target : kigi_log::memory_log::TARGET,
|
|
"MEMORY_INIT: memory disabled, no storage created"
|
|
);
|
|
None
|
|
};
|
|
let context_window_tokens = context_window_override
|
|
.map(|c| c.get())
|
|
.unwrap_or(sampling_config.context_window);
|
|
let mcp_state = {
|
|
let mut state = McpState::new_with_meta(mcp_servers.clone(), mcp_meta_config_map);
|
|
if let Some(ref pool) = parent_mcp_pool {
|
|
state.import_shared_clients(pool);
|
|
tracing::info!(
|
|
session_id = % session_info.id.0, shared_clients = state.shared_clients
|
|
.len(), "Imported shared MCP clients from parent pool"
|
|
);
|
|
}
|
|
if !acp_mcp_servers.is_empty() {
|
|
let invoker = std::sync::Arc::new(crate::session::acp_mcp::GatewayAcpInvoker::new(
|
|
gateway.clone(),
|
|
));
|
|
let acp_server_count = acp_mcp_servers.len();
|
|
state.set_acp_servers(acp_mcp_servers, invoker);
|
|
tracing::info!(
|
|
session_id = % session_info.id.0, acp_mcp_servers = acp_server_count,
|
|
"Registered in-process SDK MCP servers (kigi/mcp/sdk_call)"
|
|
);
|
|
}
|
|
Arc::new(TokioMutex::new(state))
|
|
};
|
|
let rebuild_spec = std::sync::Arc::new(crate::session::agent_rebuild::AgentRebuildSpec {
|
|
working_directory: tool_context.cwd.as_path().to_path_buf(),
|
|
terminal_backend: terminal_backend.clone(),
|
|
fs_backend: fs_backend.clone(),
|
|
tools_notification_handle: tools_notification_handle.clone(),
|
|
bridge_state_path: bridge_state_path.clone(),
|
|
session_env: tool_context.session_env.clone(),
|
|
models_manager: models_manager.clone(),
|
|
compaction_policy,
|
|
reminder_policy,
|
|
memory_enabled: memory_config.as_ref().is_some_and(|mc| mc.enabled),
|
|
memory_global_path: memory_storage_for_session
|
|
.as_ref()
|
|
.map(|s| s.global_memory_file().to_string_lossy().into_owned()),
|
|
memory_workspace_path: memory_storage_for_session
|
|
.as_ref()
|
|
.map(|s| s.workspace_memory_file().to_string_lossy().into_owned()),
|
|
memory_backend: memory_backend_for_spec,
|
|
web_search_config: web_search_config.clone(),
|
|
backend_search: backend_tools_enabled,
|
|
web_fetch_config: web_fetch_config.clone(),
|
|
app_builder_deployer_config: app_builder_deployer_config.clone(),
|
|
write_file_enabled,
|
|
subagents_enabled,
|
|
subagent_toggle: subagent_toggle.clone(),
|
|
ask_user_question_enabled,
|
|
persona_summaries: persona_summaries.clone(),
|
|
prompt_audience,
|
|
role_instructions: role_instructions.clone(),
|
|
persona_instructions: persona_instructions.clone(),
|
|
skills_config: skills_config.clone(),
|
|
compat,
|
|
context_window_tokens,
|
|
prompt_working_directory: prompt_display_cwd.clone(),
|
|
lsp: tool_context.lsp.clone(),
|
|
plugin_registry: plugin_registry.clone(),
|
|
api_key_provider: api_key_provider.clone(),
|
|
attribution_callback: attribution_callback_for_spec,
|
|
tool_params_json: tool_params_json.clone(),
|
|
subagent_event_tx: tool_context.subagent_event_tx.clone(),
|
|
monitor_event_buffer: tool_context.monitor_event_buffer.clone(),
|
|
user_question_tx: user_question_tx.clone(),
|
|
subagent_depth: tool_context.subagent_depth,
|
|
session_id_str: session_info.id.0.to_string(),
|
|
respect_gitignore,
|
|
path_not_found_hints,
|
|
mcp_state: mcp_state.clone(),
|
|
is_non_interactive: startup_hints.non_interactive,
|
|
system_prompt_label,
|
|
owner_session_id: Some(session_info.id.0.to_string()),
|
|
parent_scheduler_handle: if startup_hints.is_subagent {
|
|
parent_scheduler_handle
|
|
} else {
|
|
None
|
|
},
|
|
});
|
|
let agent = rebuild_spec
|
|
.build_agent_with_initial_overrides(
|
|
agent_definition,
|
|
persisted_announcement_state
|
|
.as_ref()
|
|
.filter(|s| !s.announced_skill_names.is_empty())
|
|
.map(|s| s.announced_skill_names.clone()),
|
|
preloaded_skills,
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(
|
|
session_id = % session_info.id.0, error = % e,
|
|
"Agent building failed, please check your config"
|
|
);
|
|
e
|
|
})?;
|
|
let resolved_task_output =
|
|
kigi_tools::reminders::task_completion::resolve_task_output_tool_name(agent.tool_bridge())
|
|
.await;
|
|
let resolved_read =
|
|
kigi_tools::reminders::task_completion::resolve_read_tool_name(agent.tool_bridge()).await;
|
|
let _ = task_output_tool_name.set(resolved_task_output.clone());
|
|
let _ = read_tool_name.set(resolved_read);
|
|
tool_context.task_output_tool_name = resolved_task_output.unwrap_or_else(|| {
|
|
kigi_tools::reminders::task_completion::DEFAULT_TASK_OUTPUT_TOOL.to_string()
|
|
});
|
|
let scheduler_handle_for_handle = {
|
|
let toolset = agent.tool_bridge().toolset();
|
|
let res = toolset.resources.lock().await;
|
|
res.get::<kigi_tools::implementations::kigi::scheduler::types::SchedulerHandle>()
|
|
.cloned()
|
|
};
|
|
if let Err(e) = workspace_ops.bind_local_session(
|
|
&session_info.id.0,
|
|
tool_context.cwd.as_path().to_path_buf(),
|
|
tool_context.hunk_tracker_handle.clone(),
|
|
agent.tool_bridge().toolset(),
|
|
None,
|
|
) {
|
|
tracing::warn!(error = % e, "failed to bind local session toolset");
|
|
}
|
|
let system_prompt = agent.system_prompt().to_string();
|
|
let mut prompt_context = agent.prompt_context().clone();
|
|
prompt_context.normalize_for_persistence();
|
|
save_prompt_context(&session_info, &prompt_context);
|
|
let is_subagent_spawn = startup_hints.is_subagent;
|
|
install_system_prompt(
|
|
&mut conversation,
|
|
&mut startup_hints.inherited_prefix_len,
|
|
is_subagent_spawn,
|
|
startup_hints.preserve_inherited_system,
|
|
&system_prompt,
|
|
);
|
|
if !startup_hints.preserve_inherited_system
|
|
&& !conversation_has_project_instructions(&conversation)
|
|
&& let Some(agents_md_reminder) = agent.agents_md_user_reminder()
|
|
{
|
|
let insert_at = conversation.len().min(1);
|
|
conversation.insert(
|
|
insert_at,
|
|
ConversationItem::project_instructions(agents_md_reminder),
|
|
);
|
|
if let Some(ref mut len) = startup_hints.inherited_prefix_len {
|
|
*len += 1;
|
|
}
|
|
}
|
|
if let Some(section) = agent.agents_md_section()
|
|
&& should_set_classifier_project_instructions(
|
|
owns_permission_manager,
|
|
Some(section.as_str()),
|
|
)
|
|
{
|
|
let body = agents_md_classifier_body(§ion);
|
|
if !body.is_empty() {
|
|
permissions.set_project_instructions(Some(body));
|
|
}
|
|
}
|
|
if let Some(ConversationItem::System(sys)) = conversation.first() {
|
|
save_system_prompt(&session_info, &sys.content);
|
|
} else {
|
|
save_system_prompt(&session_info, &system_prompt);
|
|
}
|
|
persist_chat_history_jsonl_sync(&session_info, &conversation);
|
|
chat_state_handle.replace_conversation(conversation);
|
|
let feedback_client = match (feedback_base_url, auth_manager.as_ref()) {
|
|
(Some(base_url), Some(am)) => Some(
|
|
crate::agent::feedback_client::FeedbackClient::new(base_url, am.clone())
|
|
.with_session_id(session_info.id.0.to_string()),
|
|
),
|
|
_ => None,
|
|
};
|
|
let has_feedback_client = feedback_client.is_some();
|
|
tracing::info!(
|
|
session_id = % session_info.id.0, has_feedback_client = has_feedback_client,
|
|
"Creating feedback manager"
|
|
);
|
|
let feedback_client_type = match client_type {
|
|
ClientType::KigiTUI => crate::session::feedback_types::ClientType::Tui,
|
|
ClientType::KigiWeb => crate::session::feedback_types::ClientType::Web,
|
|
ClientType::Nebula => crate::session::feedback_types::ClientType::Nebula,
|
|
ClientType::Extension => crate::session::feedback_types::ClientType::Extension,
|
|
ClientType::Generic => crate::session::feedback_types::ClientType::Agent,
|
|
ClientType::Desktop => crate::session::feedback_types::ClientType::Desktop,
|
|
ClientType::KigiPager => crate::session::feedback_types::ClientType::Tui,
|
|
};
|
|
let feedback_config = FeedbackManagerConfig {
|
|
feedback_enabled: feedback_flags.enabled,
|
|
client_type: feedback_client_type,
|
|
..Default::default()
|
|
};
|
|
let feedback_manager = Arc::new(FeedbackManager::new(
|
|
session_info.id.0.to_string(),
|
|
feedback_client,
|
|
feedback_config,
|
|
));
|
|
let signals_handle = feedback_manager.signals_handle();
|
|
if let Some(persisted) = persisted_signals {
|
|
signals_handle.restore_signals(persisted);
|
|
} else {
|
|
signals_handle.seed_counts(
|
|
initial_user_count,
|
|
initial_assistant_count,
|
|
initial_tool_call_count,
|
|
initial_tools_used,
|
|
initial_models_used,
|
|
);
|
|
}
|
|
signals_handle.set_primary_model(&primary_model_id);
|
|
signals_handle.set_tracing_config(inference_idle_timeout_secs);
|
|
let force_compact = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
let resolved_workspace_root = kigi_workspace::session::git::find_git_root_from_path(
|
|
std::path::Path::new(&session_info.cwd),
|
|
)
|
|
.ok()
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| session_info.cwd.clone());
|
|
let current_prompt_id = std::sync::Arc::new(std::sync::Mutex::new(None));
|
|
let pending_interactions: crate::session::pending_interaction::PendingInteractions =
|
|
std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
|
|
let permissions_for_handle = permissions.clone();
|
|
let (event_tx, event_rx) = mpsc::unbounded_channel::<SessionEvent>();
|
|
let mut sampler_config_initial = sampling_config.clone();
|
|
sampler_config_initial.idle_timeout_secs = Some(inference_idle_timeout_secs);
|
|
let sampler_retry_policy = kigi_sampler::RetryPolicy {
|
|
max_retries: max_retries.unwrap_or(5),
|
|
rate_limit_retry_threshold: 2,
|
|
};
|
|
let (sampler_event_tx, sampler_event_rx) =
|
|
tokio::sync::mpsc::unbounded_channel::<kigi_sampler::SamplingEvent>();
|
|
let sampler_handle = kigi_sampler::SamplerActor::spawn(
|
|
sampler_config_initial,
|
|
sampler_retry_policy,
|
|
sampler_event_tx,
|
|
);
|
|
let attribution_callback_for_handle = attribution_callback.clone();
|
|
let agent_name_for_handle = initial_agent_type
|
|
.as_deref()
|
|
.unwrap_or(crate::agent::config::DEFAULT_AGENT_TYPE)
|
|
.to_owned();
|
|
let allowed_subagent_types_for_handle = agent.definition().allowed_subagent_types.clone();
|
|
let mut hook_discovery_errors: Vec<kigi_hooks::error::HookError> = Vec::new();
|
|
let built_hook_registry: Option<Arc<kigi_hooks::discovery::HookRegistry>> =
|
|
if let Some(override_reg) = hook_registry_override {
|
|
Some(override_reg)
|
|
} else {
|
|
let cwd_path = std::path::Path::new(&session_info.cwd);
|
|
let project_trusted = crate::agent::folder_trust::resolve_and_record(
|
|
cwd_path,
|
|
remote_settings.as_ref(),
|
|
false,
|
|
);
|
|
let git_root = kigi_workspace::session::git::find_git_root_from_path(cwd_path).ok();
|
|
let (registry, errors) = crate::util::hooks::discover_hooks(
|
|
git_root.as_deref(),
|
|
&rebuild_spec.compat,
|
|
project_trusted,
|
|
);
|
|
for e in &errors {
|
|
tracing::warn!(error = ? e, "hook loading error");
|
|
}
|
|
hook_discovery_errors = errors;
|
|
if registry.is_empty() {
|
|
None
|
|
} else {
|
|
tracing::info!(hook_count = registry.len(), "loaded hooks");
|
|
Some(Arc::new(registry))
|
|
}
|
|
};
|
|
let hook_registry_for_handle = built_hook_registry.clone();
|
|
let workspace_ops_for_handle = workspace_ops.clone();
|
|
#[allow(clippy::arc_with_non_send_sync)]
|
|
let mut _hook_load_errors: Vec<String> = hook_discovery_errors
|
|
.iter()
|
|
.map(|e| e.to_string())
|
|
.collect();
|
|
let (goal_update_tx, goal_update_rx) = tokio::sync::mpsc::unbounded_channel::<
|
|
kigi_tools::implementations::kigi::update_goal::UpdateGoalEnvelope,
|
|
>();
|
|
let mut effective_config = crate::config::load_effective_config()
|
|
.ok()
|
|
.and_then(|raw| crate::agent::config::Config::new_from_toml_cfg(&raw).ok())
|
|
.unwrap_or_default();
|
|
effective_config.remote_settings = remote_settings.clone();
|
|
let goal_classifier_max_runs = effective_config.resolve_goal_classifier_max_runs().value;
|
|
let goal_strategist_every = effective_config
|
|
.resolve_goal_strategist_every(goal_classifier_max_runs)
|
|
.value;
|
|
let goal_reverify_after = effective_config.resolve_goal_reverify_after().value;
|
|
let goal_use_current_model_only = effective_config.resolve_goal_use_current_model_only().value;
|
|
let goal_role_models = {
|
|
let planner = effective_config
|
|
.resolve_goal_planner_model(goal_use_current_model_only)
|
|
.value;
|
|
let strategist = effective_config
|
|
.resolve_goal_strategist_model(goal_use_current_model_only)
|
|
.value;
|
|
let skeptic_pool = effective_config
|
|
.resolve_goal_skeptic_models(goal_use_current_model_only)
|
|
.value
|
|
.into_iter()
|
|
.filter_map(|c| match c {
|
|
crate::agent::config::GoalRoleModelChoice::Explicit(p) => Some(p),
|
|
crate::agent::config::GoalRoleModelChoice::InheritCurrent => None,
|
|
})
|
|
.collect();
|
|
GoalRoleModelConfig {
|
|
planner,
|
|
strategist,
|
|
skeptic_pool,
|
|
}
|
|
};
|
|
let doom_loop_recovery = effective_config.resolve_doom_loop_recovery();
|
|
let session_model_id_for_actor = session_model_id.clone();
|
|
let session = Arc::new_cyclic(|weak: &std::sync::Weak<SessionActor>| SessionActor {
|
|
session_info: session_info.clone(),
|
|
auth_method_id,
|
|
model_auth_facts: std::cell::RefCell::new(None),
|
|
// H4: seed the session's OWN selected catalog key from the model it was
|
|
// spawned with, resolved through the picker's lookup. Never the
|
|
// process-global `current_model_id()`. H-c: the rule lives in
|
|
// `selected_catalog_key_for_spawn` so it is covered by a test.
|
|
selected_catalog_key: std::cell::RefCell::new(
|
|
crate::agent::models::selected_catalog_key_for_spawn(
|
|
&models_manager.models(),
|
|
&session_model_id_for_actor,
|
|
),
|
|
),
|
|
attribution_callback,
|
|
auth_manager,
|
|
state,
|
|
notifications: NotificationSender {
|
|
gateway: gateway.clone(),
|
|
gateway_enabled: gateway_enabled.clone(),
|
|
persistence_tx: persistence.tx.clone(),
|
|
},
|
|
permissions,
|
|
tool_context,
|
|
deny_read_globs,
|
|
mcp_state: mcp_state.clone(),
|
|
mcp_strategy,
|
|
initial_client_mcp_servers: initial_client_mcp_servers.clone(),
|
|
chat_state_handle,
|
|
current_prompt_id: current_prompt_id.clone(),
|
|
pending_interactions: pending_interactions.clone(),
|
|
supports_backend_search: std::cell::Cell::new(sampling_config.supports_backend_search),
|
|
compactions_remaining: std::cell::Cell::new(sampling_config.compactions_remaining),
|
|
compaction_at_tokens: std::cell::Cell::new(sampling_config.compaction_at_tokens),
|
|
doom_loop_recovery,
|
|
doom_loop_turn_tally: Default::default(),
|
|
file_state_tracker,
|
|
rewind_pending_prompt: std::sync::Mutex::new(None),
|
|
startup_hints,
|
|
forked_tool_override,
|
|
compaction: super::compaction_config::CompactionConfig {
|
|
threshold_percent: std::cell::Cell::new(auto_compact_threshold_percent),
|
|
force_compact: force_compact.clone(),
|
|
context_window_override,
|
|
count: std::sync::atomic::AtomicU64::new(0),
|
|
auto_compact_suppressed: std::sync::atomic::AtomicU8::new(0),
|
|
previous_model: std::cell::Cell::new(None),
|
|
compaction_mode,
|
|
verbatim_input: compaction_verbatim_input,
|
|
prefire: crate::session::compaction_config::PrefireState::default(),
|
|
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
|
},
|
|
memory: super::memory_state::SessionMemory {
|
|
flush_config: memory_config.as_ref().map_or_else(
|
|
|| crate::config::MemoryFlushConfig {
|
|
enabled: false,
|
|
..Default::default()
|
|
},
|
|
|mc| mc.flush.clone(),
|
|
),
|
|
is_flushing: std::sync::atomic::AtomicBool::new(false),
|
|
last_flush_compaction: std::sync::atomic::AtomicU64::new(0),
|
|
storage: std::cell::RefCell::new(memory_storage_for_session),
|
|
save_on_end: memory_config
|
|
.as_ref()
|
|
.is_none_or(|mc| mc.session.save_on_end),
|
|
backend_params: memory_backend_params_for_session,
|
|
initial_injection_config: memory_initial_injection_config,
|
|
context_injected: std::sync::atomic::AtomicBool::new(false),
|
|
flush_count: std::sync::atomic::AtomicU64::new(0),
|
|
last_flush_content: std::cell::RefCell::new(None),
|
|
flush_success_count: std::sync::atomic::AtomicU64::new(0),
|
|
flush_error_count: std::sync::atomic::AtomicU64::new(0),
|
|
search_counter: std::cell::RefCell::new(memory_search_counter),
|
|
injection_count: std::sync::atomic::AtomicU64::new(0),
|
|
compaction_recovery_count: std::sync::atomic::AtomicU64::new(0),
|
|
chunks_added: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
|
dream_config: memory_config
|
|
.as_ref()
|
|
.map_or_else(Default::default, |mc| mc.dream),
|
|
dream_count: std::sync::atomic::AtomicU64::new(0),
|
|
dream_success_count: std::sync::atomic::AtomicU64::new(0),
|
|
dream_error_count: std::sync::atomic::AtomicU64::new(0),
|
|
},
|
|
session_start: std::time::Instant::now(),
|
|
inference_idle_timeout: Duration::from_secs(inference_idle_timeout_secs),
|
|
max_turns,
|
|
max_retries: kigi_sampler::resolve_max_retries(max_retries),
|
|
pending_interjections: InterjectionBuffer::new(),
|
|
pending_skill_reminders: Mutex::new(Vec::new()),
|
|
idle_flush_timeout: memory_config
|
|
.as_ref()
|
|
.and_then(|mc| mc.flush.idle_timeout_secs)
|
|
.map(std::time::Duration::from_secs),
|
|
dream_check_timeout: memory_config
|
|
.as_ref()
|
|
.filter(|mc| mc.dream.enabled)
|
|
.and_then(|mc| mc.dream.check_interval_secs)
|
|
.filter(|&s| s > 0)
|
|
.map(std::time::Duration::from_secs),
|
|
last_idle_flush_conversation_len: std::sync::atomic::AtomicUsize::new(
|
|
initial_conversation_len,
|
|
),
|
|
event_tx,
|
|
buffering_settings,
|
|
client_identifier: session_client_identifier.clone(),
|
|
origin_client: origin_client.clone(),
|
|
feedback_manager: feedback_manager.clone(),
|
|
agent: std::cell::RefCell::new(agent),
|
|
last_reported_branch: Arc::new(Mutex::new(None)),
|
|
git_head_enabled: fs_watch_caps.git_head,
|
|
models_manager,
|
|
display_cwd: {
|
|
let lock = std::sync::OnceLock::new();
|
|
if let Some(ref cwd) = prompt_display_cwd {
|
|
let _ = lock.set(cwd.clone());
|
|
}
|
|
lock
|
|
},
|
|
active_agent_type: parking_lot::Mutex::new(initial_agent_type),
|
|
queue_exit_reminder_on_approved_exit,
|
|
active_skill: parking_lot::Mutex::new(None),
|
|
current_prompt_mode: current_prompt_mode.clone(),
|
|
turn_start_prompt_mode: parking_lot::Mutex::new(PromptMode::Agent),
|
|
turn_prompt_mode: turn_prompt_mode.clone(),
|
|
plan_mode: plan_mode.clone(),
|
|
goal_enabled,
|
|
goal_harness_enabled: std::sync::atomic::AtomicBool::new(false),
|
|
goal_harness_availability_reconciled: std::sync::atomic::AtomicBool::new(false),
|
|
goal_tracker,
|
|
graph_enabled,
|
|
graph_tracker,
|
|
graph_concurrency: effective_config.resolve_graph_concurrency(),
|
|
graph_node_rounds: effective_config.resolve_graph_node_rounds(),
|
|
graph_replan_cap: effective_config.resolve_graph_replan_cap(),
|
|
graph_optimizer_enabled: effective_config.resolve_graph_optimizer_enabled(),
|
|
graph_project_dir,
|
|
graph_project_lock: std::cell::RefCell::new(None),
|
|
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
|
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
|
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
|
|
goal_update_rx: std::cell::RefCell::new(Some(goal_update_rx)),
|
|
goal_update_tx,
|
|
goal_classifier_enabled: effective_config
|
|
.resolve_goal_classifier_enabled(goal_enabled)
|
|
.value,
|
|
goal_planner_enabled: effective_config
|
|
.resolve_goal_planner_enabled(goal_enabled)
|
|
.value,
|
|
goal_summary_enabled: effective_config
|
|
.resolve_goal_summary_enabled(goal_enabled)
|
|
.value,
|
|
goal_verifier_skeptic_count: effective_config.resolve_goal_verifier_count().value,
|
|
goal_role_models,
|
|
goal_use_current_model_only,
|
|
goal_classifier_max_runs,
|
|
goal_strategist_every,
|
|
goal_reverify_after,
|
|
goal_plan_reconciled: std::sync::atomic::AtomicBool::new(false),
|
|
pending_classifier_completions: parking_lot::Mutex::new(VecDeque::new()),
|
|
goal_classifier_in_flight: std::sync::atomic::AtomicBool::new(false),
|
|
tool_metadata_snapshot: Arc::new(std::sync::Mutex::new(Default::default())),
|
|
mcp_announced_servers: Mutex::new(
|
|
persisted_announcement_state
|
|
.as_ref()
|
|
.map(|s| {
|
|
crate::session::announcement_state::from_persisted_fingerprints(
|
|
&s.mcp_server_fingerprints,
|
|
)
|
|
})
|
|
.unwrap_or_default(),
|
|
),
|
|
mcp_reminder_mode: McpReminderMode::from_env(),
|
|
mcp_reminder_dirty: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
|
mcp_connecting_reminder_injected: std::cell::Cell::new(false),
|
|
mcp_handshakes_done: Arc::new(tokio::sync::Notify::new()),
|
|
user_input_generation: std::sync::atomic::AtomicU64::new(0),
|
|
laziness_debug_log: laziness_debug_log.map(|p| std::sync::Arc::from(p.as_path())),
|
|
deferred_prefix: TaskSlot::new(),
|
|
extension_registry: session_extension_registry(weak.clone()),
|
|
last_announced_local_date: std::cell::Cell::new(chrono::Local::now().date_naive()),
|
|
last_search_prompt_index: std::sync::atomic::AtomicI64::new(-1),
|
|
last_api_request_at: std::sync::atomic::AtomicI64::new(0),
|
|
hook_registry: std::cell::RefCell::new(built_hook_registry),
|
|
client_hooks: std::cell::RefCell::new(client_hooks),
|
|
hook_resolved_workspace_root: resolved_workspace_root,
|
|
vcs_kind: {
|
|
let root = std::path::Path::new(&session_info.cwd);
|
|
match kigi_workspace::session::git::discover_git_root(root) {
|
|
kigi_workspace::session::git::GitDiscoveryResult::Found(git_root) => {
|
|
kigi_workspace::session::git::detect_vcs_kind(&git_root)
|
|
}
|
|
_ => kigi_workspace::session::git::VcsKind::None,
|
|
}
|
|
},
|
|
hook_load_errors: std::cell::RefCell::new(_hook_load_errors),
|
|
plugin_registry: std::cell::RefCell::new(plugin_registry.clone()),
|
|
plugin_registry_handle,
|
|
events: crate::session::events::EventTracker::new(
|
|
&crate::session::persistence::session_dir(&session_info),
|
|
),
|
|
current_turn_number: std::cell::Cell::new(0),
|
|
last_recap_main_turn: std::cell::Cell::new(0),
|
|
recap_in_flight: std::cell::Cell::new(false),
|
|
recap_epoch: std::cell::Cell::new(0),
|
|
session_turn_active: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
|
streaming_turn_capture: parking_lot::Mutex::new(StreamingTurnCapture::default()),
|
|
turn_stream_drained: parking_lot::Mutex::new(None),
|
|
sampler_handle,
|
|
rebuild_spec: rebuild_spec.clone(),
|
|
image_description_model,
|
|
image_describe_cache: Arc::new(crate::session::image_describe::ImageDescribeCache::new()),
|
|
subagent_spawn_info: parking_lot::Mutex::new(HashMap::new()),
|
|
subagent_token_records: parking_lot::Mutex::new(HashMap::new()),
|
|
workspace_ops: workspace_ops.clone(),
|
|
});
|
|
{
|
|
let drainer_session = session.clone();
|
|
let mut sampler_event_rx = sampler_event_rx;
|
|
tokio::task::spawn_local(async move {
|
|
while let Some(event) = sampler_event_rx.recv().await {
|
|
drainer_session.handle_sampling_event(event).await;
|
|
}
|
|
tracing::debug!("sampler event drainer exiting (channel closed)");
|
|
});
|
|
}
|
|
{
|
|
let drainer_session = session.clone();
|
|
let Some(mut goal_update_rx) = session.goal_update_rx.borrow_mut().take() else {
|
|
unreachable!("goal_update_rx must be Some at session spawn");
|
|
};
|
|
tokio::task::spawn_local(async move {
|
|
while let Some(envelope) = goal_update_rx.recv().await {
|
|
let current_tokens =
|
|
drainer_session.chat_state_handle.get_total_tokens().await as i64;
|
|
drainer_session
|
|
.drain_goal_updates_with_extra(
|
|
current_tokens,
|
|
DrainPurpose::MidTurn,
|
|
vec![envelope],
|
|
)
|
|
.await;
|
|
}
|
|
tracing::debug!("goal update drainer exiting (channel closed)");
|
|
});
|
|
}
|
|
{
|
|
let snapshot = session.tool_metadata_snapshot.clone();
|
|
let tool_index = crate::session::tool_index::Bm25ToolSearchIndex::new(snapshot);
|
|
session
|
|
.agent
|
|
.borrow()
|
|
.tool_bridge()
|
|
.update_resource(kigi_tools::types::tool_index::ToolIndex(
|
|
std::sync::Arc::new(tool_index),
|
|
))
|
|
.await;
|
|
}
|
|
{
|
|
let plan_path = session.plan_mode.lock().plan_file_path().to_path_buf();
|
|
session
|
|
.agent
|
|
.borrow()
|
|
.tool_bridge()
|
|
.update_resource(kigi_tools::types::resources::PlanFilePath(plan_path))
|
|
.await;
|
|
}
|
|
session.inject_deny_read_globs().await;
|
|
if session.permissions.is_auto_mode() {
|
|
session.wire_permission_auto_llm_classifier().await;
|
|
}
|
|
session
|
|
.agent
|
|
.borrow()
|
|
.tool_bridge()
|
|
.update_resource(
|
|
kigi_tools::implementations::kigi::update_goal::GoalUpdateHandle(
|
|
session.goal_update_tx.clone(),
|
|
),
|
|
)
|
|
.await;
|
|
// A restored graph was demoted (Active→UserPaused, Running→Ready) IN
|
|
// MEMORY after the updates-log replay, whose last GraphUpdated still
|
|
// shows the pre-shutdown Active state. Re-emit truth once so a
|
|
// reattached pager never renders a stale self-driving chip — and
|
|
// best-effort reclaim project writership so the shared file gets the
|
|
// demoted truth too (Busy = another instance owns it; skip quietly).
|
|
if session.graph_tracker.lock().snapshot().is_some() {
|
|
if let Some(msg) = session.claim_project_graph_for_resume() {
|
|
tracing::info!(%msg, "graph restore: project writership not reclaimed");
|
|
}
|
|
session.persist_graph_state();
|
|
}
|
|
if let Some(ref display_cwd) = prompt_display_cwd {
|
|
session
|
|
.agent
|
|
.borrow()
|
|
.tool_bridge()
|
|
.set_display_cwd(std::path::PathBuf::from(display_cwd))
|
|
.await;
|
|
}
|
|
if let Some(storage) = session.memory.storage() {
|
|
crate::session::memory::init_sqlite_vec();
|
|
let index_config = memory_config
|
|
.as_ref()
|
|
.map_or_else(Default::default, |mc| mc.index.clone());
|
|
let embed_config = memory_config
|
|
.as_ref()
|
|
.map(|mc| mc.embedding.clone())
|
|
.unwrap_or_default();
|
|
let embed_dims = embed_config.dimensions;
|
|
let sampling_base_url = embed_base_url.clone();
|
|
let sampling_api_key = embed_api_key.clone();
|
|
let session_id_for_reindex = session_info.id.to_string();
|
|
let chunks_added_counter = session.memory.chunks_added.clone();
|
|
tokio::task::spawn_local(async move {
|
|
let db_path = storage.workspace_dir().join("index.sqlite");
|
|
if let Ok(mut index) = crate::session::memory::MemoryIndex::open_or_create(
|
|
&db_path,
|
|
storage.clone(),
|
|
index_config,
|
|
embed_dims,
|
|
) && let Ok(files) = storage.list_memory_files()
|
|
{
|
|
let mut total_added = 0;
|
|
for file in &files {
|
|
let source = storage.classify_source(file);
|
|
if let Ok(stats) = index.reindex_file(file, source) {
|
|
total_added += stats.added;
|
|
}
|
|
}
|
|
tracing::info!(
|
|
target : kigi_log::memory_log::TARGET, files = files.len(),
|
|
"MEMORY_REINDEX: background reindex complete"
|
|
);
|
|
if let Some(api_key) = sampling_api_key
|
|
&& let Some(provider) =
|
|
crate::session::memory::embedding::ApiEmbeddingProvider::from_session(
|
|
&embed_config,
|
|
sampling_base_url,
|
|
api_key,
|
|
)
|
|
{
|
|
crate::session::memory::embed_missing_chunks(&index, &provider).await;
|
|
}
|
|
chunks_added_counter
|
|
.fetch_add(total_added as u64, std::sync::atomic::Ordering::Relaxed);
|
|
}
|
|
});
|
|
}
|
|
{
|
|
use agent_client_protocol::Client as _;
|
|
use kigi_tools::implementations::kigi::ask_user_question::{
|
|
AskUserQuestionExtRequest, AskUserQuestionExtResponse, UserQuestionError,
|
|
UserQuestionResponse,
|
|
};
|
|
let gateway = session.notifications.gateway.clone();
|
|
let session_id = session.session_info.id.clone();
|
|
let current_prompt_mode = session.current_prompt_mode.clone();
|
|
let pending_interactions = session.pending_interactions.clone();
|
|
let session_for_hooks = session.clone();
|
|
let mut user_question_rx = user_question_rx;
|
|
tokio::task::spawn_local(async move {
|
|
while let Some(mut request) = user_question_rx.recv().await {
|
|
use kigi_tools::implementations::kigi::ask_user_question::AskUserQuestionMode;
|
|
let mode = match *current_prompt_mode.lock() {
|
|
PromptMode::Plan => AskUserQuestionMode::Plan,
|
|
_ => AskUserQuestionMode::Default,
|
|
};
|
|
let ext_req = AskUserQuestionExtRequest {
|
|
session_id: session_id.0.to_string(),
|
|
tool_call_id: request.tool_call_id.clone(),
|
|
questions: request.questions.clone(),
|
|
mode,
|
|
};
|
|
debug_assert!(
|
|
!ext_req.session_id.is_empty(),
|
|
"ask_user_question reverse-request must carry a non-empty sessionId (design §5.4)"
|
|
);
|
|
let ext_request = agent_client_protocol::ExtRequest::new(
|
|
"kigi/ask_user_question",
|
|
serde_json::value::to_raw_value(&ext_req)
|
|
.expect("AskUserQuestionExtRequest serialization should not fail")
|
|
.into(),
|
|
);
|
|
session_for_hooks
|
|
.dispatch_notification_hook(
|
|
"elicitation_dialog",
|
|
Some("User question requested".into()),
|
|
None,
|
|
Some("info".into()),
|
|
)
|
|
.await;
|
|
let questions_for_response = request.questions.clone();
|
|
let tool_call_id = request.tool_call_id.clone();
|
|
let result = {
|
|
let _pending_guard =
|
|
crate::session::pending_interaction::PendingInteractionGuard::new(
|
|
pending_interactions.clone(),
|
|
gateway.clone(),
|
|
session_id.clone(),
|
|
tool_call_id.clone(),
|
|
crate::session::pending_interaction::PendingKind::Question,
|
|
);
|
|
tokio::select! {
|
|
biased; () = request.result_tx.closed() => { tracing::info!(%
|
|
tool_call_id,
|
|
"ask_user_question tool receiver closed (timeout or cancel); abandoning ACP wait");
|
|
Ok(UserQuestionResponse::Cancelled) } acp_result = gateway
|
|
.ext_method(ext_request) => { match acp_result { Ok(raw) => {
|
|
match serde_json::from_str::< AskUserQuestionExtResponse > (raw.0
|
|
.get(),) { Ok(typed) => { Ok(typed
|
|
.into_response(questions_for_response)) } Err(e) =>
|
|
Err(UserQuestionError::MalformedResponse(e.to_string(),)), } }
|
|
Err(e) => Err(UserQuestionError::TransportError(e.to_string())),
|
|
} }
|
|
}
|
|
};
|
|
let _ = request.result_tx.send(result);
|
|
}
|
|
});
|
|
}
|
|
let (session_done_tx, session_done_rx) = tokio::sync::oneshot::channel::<()>();
|
|
let telemetry_ctx = session.session_info.id.0.to_string();
|
|
{
|
|
let hooks: Vec<super::telemetry::HookRegInfo> = session
|
|
.hook_registry
|
|
.borrow()
|
|
.as_ref()
|
|
.map(|reg| {
|
|
reg.all_hooks()
|
|
.iter()
|
|
.map(|s| super::telemetry::HookRegInfo::from_spec(s))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
super::telemetry::emit_session_registration_spans(
|
|
session.plugin_registry.borrow().as_deref(),
|
|
&hooks,
|
|
);
|
|
}
|
|
tokio::task::spawn_local(async move {
|
|
kigi_log::session_ctx::with_session_ctx(
|
|
&telemetry_ctx,
|
|
run_session(
|
|
session,
|
|
cmd_rx,
|
|
chat_state_event_rx,
|
|
event_rx,
|
|
fs_notify_config,
|
|
codebase_indexes,
|
|
index_root_for_session,
|
|
fs_watch_caps,
|
|
),
|
|
)
|
|
.await;
|
|
let _ = session_done_tx.send(());
|
|
});
|
|
Ok((
|
|
SessionHandle {
|
|
cmd_tx,
|
|
persistence_tx: persistence.tx.clone(),
|
|
current_prompt_id,
|
|
pending_interactions,
|
|
info: session_info,
|
|
max_turns,
|
|
hunk_tracker_handle,
|
|
chat_state_handle: chat_state_handle_for_handle,
|
|
signals_handle,
|
|
gateway_enabled,
|
|
mcp_servers,
|
|
initial_client_mcp_servers,
|
|
display_cwd: None,
|
|
feedback_manager: feedback_manager.clone(),
|
|
tool_context: tool_context_for_handle,
|
|
model_id: session_model_id,
|
|
reasoning_effort: sampling_config.reasoning_effort,
|
|
yolo_mode: session_yolo_mode,
|
|
origin_client: origin_client.clone(),
|
|
code_nav_enabled,
|
|
ask_user_question_enabled,
|
|
plan_mode: plan_mode.clone(),
|
|
force_compact,
|
|
permission_handle: permissions_for_handle,
|
|
attribution_callback: attribution_callback_for_handle,
|
|
agent_name: agent_name_for_handle,
|
|
session_default_agent_profile,
|
|
allowed_subagent_types: allowed_subagent_types_for_handle,
|
|
hook_registry: hook_registry_for_handle,
|
|
workspace_ops: workspace_ops_for_handle,
|
|
terminal_backend: Some(terminal_backend.clone()),
|
|
tools_notification_handle: Some(tools_notification_handle.clone()),
|
|
scheduler_handle: scheduler_handle_for_handle,
|
|
},
|
|
system_prompt,
|
|
session_done_rx,
|
|
))
|
|
}
|
|
/// Handle for a session's dedicated thread. Stored separately from `SessionHandle`
|
|
/// (which derives `Clone`) because `JoinHandle` is not `Clone`.
|
|
pub struct SessionThread {
|
|
join_handle: std::thread::JoinHandle<()>,
|
|
}
|
|
impl SessionThread {
|
|
/// Check if the session thread has exited (panicked or finished).
|
|
pub fn is_finished(&self) -> bool {
|
|
self.join_handle.is_finished()
|
|
}
|
|
/// Construct from a raw `JoinHandle`. Used in tests.
|
|
#[cfg(test)]
|
|
pub fn from_handle(handle: std::thread::JoinHandle<()>) -> Self {
|
|
Self {
|
|
join_handle: handle,
|
|
}
|
|
}
|
|
}
|
|
/// Return type from the session thread's initialization, sent via oneshot.
|
|
struct SessionInitResult {
|
|
handle: SessionHandle,
|
|
system_prompt: String,
|
|
}
|
|
/// Spawn a session actor on a dedicated thread with its own tokio runtime and `LocalSet`.
|
|
///
|
|
/// The entire `spawn_session_actor` body runs on the session thread — the `!Send`
|
|
/// `SessionActor` is constructed there and never crosses a thread boundary. The
|
|
/// `Send` construction parameters are moved into the thread, and the `Send` results
|
|
/// (`SessionHandle`, `system_prompt`) are sent back to the
|
|
/// caller via a oneshot channel.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) async fn spawn_session_on_thread(
|
|
session_info: SessionInfo,
|
|
gateway: GatewaySender,
|
|
sampling_config: SamplingConfig,
|
|
credentials: kigi_chat_state::Credentials,
|
|
auth_method_id: crate::agent::auth_method::SharedAuthMethodId,
|
|
auth_manager: Option<Arc<AuthManager>>,
|
|
attribution_callback: Option<kigi_sampler::SharedAttributionCallback>,
|
|
tool_context: ToolContext,
|
|
mcp_servers: Vec<acp::McpServer>,
|
|
initial_client_mcp_servers: Vec<acp::McpServer>,
|
|
mcp_meta_config_map: McpMetaConfigMap,
|
|
parent_mcp_pool: Option<crate::session::mcp_servers::SharedMcpPool>,
|
|
acp_mcp_servers: Vec<crate::session::mcp_servers::AcpServerEntry>,
|
|
support_permission: bool,
|
|
auto_update: Option<bool>,
|
|
persistence: PersistenceHandle,
|
|
conversation: Vec<ConversationItem>,
|
|
rewind_points_path: Option<std::path::PathBuf>,
|
|
fs_notify_config: Option<ClientFsConfig>,
|
|
initial_total_tokens: u64,
|
|
startup_hints: StartupHints,
|
|
client_type: ClientType,
|
|
auto_compact_threshold_percent: u8,
|
|
system_prompt_label: String,
|
|
compaction_mode: kigi_chat_state::CompactionMode,
|
|
compaction_verbatim_input: bool,
|
|
two_pass_enabled: bool,
|
|
buffering_settings: Option<BufferingSettings>,
|
|
origin_client: Option<crate::http::OriginClientInfo>,
|
|
codebase_indexes: std::sync::Arc<parking_lot::Mutex<CodebaseIndexManager>>,
|
|
code_nav_enabled: bool,
|
|
fs_watch_caps: fs_watch::FsWatchCapabilities,
|
|
feedback_base_url: Option<String>,
|
|
client_terminal_capable: bool,
|
|
client_fs_capable: bool,
|
|
gateway_enabled: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
|
agent_definition: AgentDefinition,
|
|
session_default_agent_profile: Option<String>,
|
|
skills_config: SkillsConfig,
|
|
preloaded_skills: Option<Vec<kigi_tools::implementations::skills::types::SkillInfo>>,
|
|
compat: CompatConfig,
|
|
incremental_bash_output: bool,
|
|
persisted_signals: Option<crate::session::signals::SessionSignals>,
|
|
persisted_plan_mode: Option<crate::session::plan_mode::PlanModeSnapshot>,
|
|
persisted_goal_mode: Option<crate::session::goal_tracker::GoalOrchestration>,
|
|
persisted_graph_mode: Option<crate::session::graph_tracker::GraphOrchestration>,
|
|
persisted_announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
|
|
memory_config: Option<crate::config::MemoryConfig>,
|
|
feedback_flags: crate::session::feedback_manager::FeedbackFlags,
|
|
session_model_id: acp::ModelId,
|
|
session_yolo_mode: bool,
|
|
session_auto_mode: bool,
|
|
session_client_identifier: Option<String>,
|
|
inference_idle_timeout_secs: u64,
|
|
max_retries: Option<u32>,
|
|
web_search_config: kigi_tools::implementations::WebSearchConfig,
|
|
web_fetch_config: kigi_tools::implementations::kigi::web_fetch::WebFetchConfig,
|
|
app_builder_deployer_config: kigi_tools::implementations::kigi::deploy_app::AppBuilderDeployerConfig,
|
|
write_file_enabled: bool,
|
|
goal_enabled: bool,
|
|
graph_enabled: bool,
|
|
subagents_enabled: bool,
|
|
ask_user_question_enabled: bool,
|
|
client_hooks: crate::extensions::hooks::ClientHooks,
|
|
prompt_display_cwd: Option<String>,
|
|
subagent_toggle: std::collections::HashMap<String, bool>,
|
|
persona_summaries: Vec<String>,
|
|
prompt_audience: kigi_agent::prompt::context::PromptAudience,
|
|
role_instructions: Option<String>,
|
|
persona_instructions: Option<String>,
|
|
disable_web_search: bool,
|
|
backend_tools_enabled: bool,
|
|
respect_gitignore: bool,
|
|
path_not_found_hints: bool,
|
|
tool_params_json: crate::session::agent_rebuild::ResolvedToolParamsJson,
|
|
plugin_registry: Option<std::sync::Arc<kigi_agent::plugins::PluginRegistry>>,
|
|
plugin_registry_handle: Option<kigi_agent::plugins::SharedPluginRegistryHandle>,
|
|
models_manager: crate::agent::models::ModelsManager,
|
|
parent_traceparent: Option<String>,
|
|
inherited_permission_handle: Option<kigi_workspace::permission::PermissionHandle>,
|
|
api_key_provider: Option<kigi_tools::types::SharedApiKeyProvider>,
|
|
image_description_model: String,
|
|
hook_registry_override: Option<std::sync::Arc<kigi_hooks::discovery::HookRegistry>>,
|
|
workspace_ops: kigi_workspace::WorkspaceOps,
|
|
cli_permission_rules: Vec<kigi_workspace::permission::types::PermissionRule>,
|
|
todo_gate: bool,
|
|
remote_settings: Option<crate::util::config::RemoteSettings>,
|
|
laziness_debug_log: Option<std::path::PathBuf>,
|
|
parent_terminal_backend: Option<
|
|
std::sync::Arc<dyn kigi_tools::computer::types::TerminalBackend>,
|
|
>,
|
|
parent_scheduler_handle: Option<
|
|
kigi_tools::implementations::kigi::scheduler::types::SchedulerHandle,
|
|
>,
|
|
max_turns: Option<usize>,
|
|
forked_tool_override: Option<Vec<ToolSpec>>,
|
|
) -> Result<(SessionHandle, String, SessionThread), acp::Error> {
|
|
let (init_tx, init_rx) =
|
|
tokio::sync::oneshot::channel::<Result<SessionInitResult, kigi_agent::AgentBuildError>>();
|
|
let sid = session_info.id.0.to_string();
|
|
let thread_name = format!("ses-{}", &sid[..sid.len().min(8)]);
|
|
const SESSION_THREAD_STACK_SIZE: usize = 8 * 1024 * 1024;
|
|
let join_handle = std::thread::Builder::new()
|
|
.name(thread_name)
|
|
.stack_size(SESSION_THREAD_STACK_SIZE)
|
|
.spawn(move || {
|
|
let (initial_last_compaction, initial_prompt_texts) = {
|
|
let session_dir = crate::session::persistence::session_dir(&session_info);
|
|
let updates_path = session_dir.join("updates.jsonl");
|
|
let initial_last_compaction = {
|
|
let _timer = crate::instrumentation_timer!(
|
|
"session.spawn_actor.find_compaction_checkpoint"
|
|
);
|
|
crate::session::helpers::replay::find_latest_compaction_checkpoint(
|
|
&updates_path,
|
|
)
|
|
.ok()
|
|
.flatten()
|
|
.map(|cp| cp.prompt_index_at_compaction)
|
|
};
|
|
let initial_prompt_texts = {
|
|
let _timer =
|
|
crate::instrumentation_timer!("session.spawn_actor.load_user_prompts");
|
|
SessionActor::load_user_prompts_from_updates(&updates_path).unwrap_or_default()
|
|
};
|
|
(initial_last_compaction, initial_prompt_texts)
|
|
};
|
|
let rt = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.expect("session runtime");
|
|
let local = tokio::task::LocalSet::new();
|
|
local.block_on(&rt, async move {
|
|
let _trace_span = parent_traceparent.as_ref().map(|tp| {
|
|
let meta = serde_json::json!({ "traceparent" : tp })
|
|
.as_object()
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
let span = kigi_file_utils::trace_context::span_from_meta_traceparent(&meta);
|
|
span.entered()
|
|
});
|
|
let (handle, system_prompt, session_done_rx) = match spawn_session_actor(
|
|
session_info,
|
|
gateway,
|
|
sampling_config,
|
|
credentials,
|
|
auth_method_id,
|
|
auth_manager,
|
|
attribution_callback,
|
|
tool_context,
|
|
mcp_servers,
|
|
initial_client_mcp_servers,
|
|
mcp_meta_config_map,
|
|
parent_mcp_pool,
|
|
acp_mcp_servers,
|
|
support_permission,
|
|
auto_update,
|
|
persistence,
|
|
conversation,
|
|
rewind_points_path,
|
|
initial_last_compaction,
|
|
initial_prompt_texts,
|
|
fs_notify_config,
|
|
initial_total_tokens,
|
|
startup_hints,
|
|
client_type,
|
|
auto_compact_threshold_percent,
|
|
system_prompt_label,
|
|
compaction_mode,
|
|
compaction_verbatim_input,
|
|
two_pass_enabled,
|
|
buffering_settings,
|
|
origin_client,
|
|
codebase_indexes,
|
|
code_nav_enabled,
|
|
fs_watch_caps,
|
|
feedback_base_url,
|
|
client_terminal_capable,
|
|
client_fs_capable,
|
|
gateway_enabled,
|
|
agent_definition,
|
|
session_default_agent_profile,
|
|
skills_config,
|
|
preloaded_skills,
|
|
compat,
|
|
incremental_bash_output,
|
|
persisted_signals,
|
|
persisted_plan_mode,
|
|
persisted_goal_mode,
|
|
persisted_graph_mode,
|
|
persisted_announcement_state,
|
|
memory_config,
|
|
feedback_flags,
|
|
session_model_id,
|
|
session_yolo_mode,
|
|
session_auto_mode,
|
|
session_client_identifier,
|
|
inference_idle_timeout_secs,
|
|
max_retries,
|
|
web_search_config,
|
|
web_fetch_config,
|
|
app_builder_deployer_config,
|
|
write_file_enabled,
|
|
goal_enabled,
|
|
graph_enabled,
|
|
subagents_enabled,
|
|
ask_user_question_enabled,
|
|
client_hooks,
|
|
prompt_display_cwd,
|
|
subagent_toggle,
|
|
persona_summaries,
|
|
prompt_audience,
|
|
role_instructions,
|
|
persona_instructions,
|
|
disable_web_search,
|
|
backend_tools_enabled,
|
|
respect_gitignore,
|
|
path_not_found_hints,
|
|
tool_params_json,
|
|
plugin_registry,
|
|
plugin_registry_handle,
|
|
models_manager,
|
|
inherited_permission_handle,
|
|
api_key_provider,
|
|
image_description_model,
|
|
hook_registry_override,
|
|
workspace_ops,
|
|
cli_permission_rules,
|
|
todo_gate,
|
|
remote_settings,
|
|
laziness_debug_log,
|
|
parent_terminal_backend,
|
|
parent_scheduler_handle,
|
|
max_turns,
|
|
forked_tool_override,
|
|
)
|
|
.await
|
|
{
|
|
Ok(result) => result,
|
|
Err(e) => {
|
|
let _ = init_tx.send(Err(e));
|
|
return;
|
|
}
|
|
};
|
|
let _ = init_tx.send(Ok(SessionInitResult {
|
|
handle,
|
|
system_prompt,
|
|
}));
|
|
let _ = session_done_rx.await;
|
|
});
|
|
})
|
|
.expect("spawn session thread");
|
|
let init = init_rx
|
|
.await
|
|
.map_err(|_| {
|
|
tracing::error!("Session thread panicked during initialization");
|
|
acp::Error::internal_error().data("session thread panicked during initialization")
|
|
})?
|
|
.map_err(|e| acp::Error::internal_error().data(format!("agent building failed: {e}")))?;
|
|
Ok((
|
|
init.handle,
|
|
init.system_prompt,
|
|
SessionThread { join_handle },
|
|
))
|
|
}
|
|
/// Production [`crate::session::mcp_restart::RestartActions`] impl.
|
|
///
|
|
/// Captured by the dispatcher task at session startup when
|
|
/// `mcp.auto_restart=true`. Holds an `Arc<SessionActor>` plus the
|
|
/// dispatcher's `SharedShutdownState` so:
|
|
///
|
|
/// - `is_stdio_server_configured` resolves against
|
|
/// [`SessionActor::is_stdio_server_configured`] (which reads
|
|
/// `McpState::configs`).
|
|
/// - `is_in_shutting_down` peeks at the dispatcher's set.
|
|
/// - `respawn_stdio` delegates to
|
|
/// [`SessionActor::respawn_stdio`] (re-runs `start_mcp_server`,
|
|
/// handshake, liveness arm, owned_clients swap).
|
|
/// - `push_status` forwards directly via the session's gateway.
|
|
pub(crate) struct SessionRestartActions {
|
|
session: Arc<SessionActor>,
|
|
shutdown: crate::session::mcp_dispatcher::SharedShutdownState,
|
|
}
|
|
impl SessionRestartActions {
|
|
pub(crate) fn new(
|
|
session: Arc<SessionActor>,
|
|
shutdown: crate::session::mcp_dispatcher::SharedShutdownState,
|
|
) -> Self {
|
|
Self { session, shutdown }
|
|
}
|
|
}
|
|
#[async_trait::async_trait(?Send)]
|
|
impl crate::session::mcp_restart::RestartActions for SessionRestartActions {
|
|
async fn is_stdio_server_configured(&self, server: &str) -> bool {
|
|
self.session.is_stdio_server_configured(server).await
|
|
}
|
|
fn is_in_shutting_down(&self, server: &str) -> bool {
|
|
self.shutdown
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.is_shutting_down(server)
|
|
}
|
|
async fn respawn_stdio(&self, server: &str) -> Result<(), String> {
|
|
self.session.respawn_stdio(server).await
|
|
}
|
|
async fn is_http_server_configured(&self, server: &str) -> bool {
|
|
self.session.is_http_server_configured(server).await
|
|
}
|
|
async fn reset_http_client(&self, server: &str) -> Result<(), String> {
|
|
self.session.reset_http_client(server).await
|
|
}
|
|
fn unregister_server_tools(&self, server: &str) {
|
|
self.session.unregister_server_tools(server);
|
|
}
|
|
fn push_status(&self, payload: &crate::session::mcp_dispatcher::McpServerStatusPayload) {
|
|
crate::session::mcp_restart::forward_status(&self.session.notifications.gateway, payload);
|
|
}
|
|
fn begin_restart(&self, server: &str) -> bool {
|
|
self.shutdown
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.begin_restart(server.to_string())
|
|
}
|
|
fn end_restart(&self, server: &str) {
|
|
self.shutdown
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.end_restart(server);
|
|
}
|
|
}
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum TerminalBackendKind {
|
|
ReuseParent,
|
|
AcpClient,
|
|
LocalPersistent,
|
|
LocalNonPersistent,
|
|
}
|
|
fn select_terminal_backend_kind(
|
|
is_subagent: bool,
|
|
has_parent_backend: bool,
|
|
client_terminal_capable: bool,
|
|
has_gateway: bool,
|
|
local_persistent: bool,
|
|
) -> TerminalBackendKind {
|
|
if is_subagent && has_parent_backend {
|
|
TerminalBackendKind::ReuseParent
|
|
} else if client_terminal_capable && has_gateway {
|
|
TerminalBackendKind::AcpClient
|
|
} else if local_persistent {
|
|
TerminalBackendKind::LocalPersistent
|
|
} else {
|
|
TerminalBackendKind::LocalNonPersistent
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
mod terminal_backend_select_tests {
|
|
use super::{TerminalBackendKind, select_terminal_backend_kind};
|
|
#[test]
|
|
fn subagent_with_parent_reuses_parent() {
|
|
assert_eq!(
|
|
select_terminal_backend_kind(true, true, true, true, true),
|
|
TerminalBackendKind::ReuseParent
|
|
);
|
|
}
|
|
#[test]
|
|
fn subagent_without_parent_falls_through() {
|
|
assert_eq!(
|
|
select_terminal_backend_kind(true, false, true, true, true),
|
|
TerminalBackendKind::AcpClient
|
|
);
|
|
assert_eq!(
|
|
select_terminal_backend_kind(true, false, false, true, true),
|
|
TerminalBackendKind::LocalPersistent
|
|
);
|
|
}
|
|
#[test]
|
|
fn non_subagent_never_reuses_parent() {
|
|
assert_eq!(
|
|
select_terminal_backend_kind(false, true, false, false, true),
|
|
TerminalBackendKind::LocalPersistent
|
|
);
|
|
}
|
|
#[test]
|
|
fn client_terminal_uses_acp_only_with_gateway() {
|
|
assert_eq!(
|
|
select_terminal_backend_kind(false, false, true, true, true),
|
|
TerminalBackendKind::AcpClient
|
|
);
|
|
assert_eq!(
|
|
select_terminal_backend_kind(false, false, true, false, true),
|
|
TerminalBackendKind::LocalPersistent
|
|
);
|
|
}
|
|
#[test]
|
|
fn local_session_persistent_flag_selects_backend() {
|
|
assert_eq!(
|
|
select_terminal_backend_kind(false, false, false, false, true),
|
|
TerminalBackendKind::LocalPersistent
|
|
);
|
|
assert_eq!(
|
|
select_terminal_backend_kind(false, false, false, false, false),
|
|
TerminalBackendKind::LocalNonPersistent
|
|
);
|
|
}
|
|
}
|