A deterministic DAG scheduler layered on the existing goal engine: /graph <objective> decomposes the objective via a graph-planner subagent, gates the result through Agentproof-style static validation (cycles, unknown deps, duplicate slugs, caps), appends a structural final-verification node, then executes each node as one ordinary goal — planner, worker loop, adversarial verifier, budget and pause machinery all reused verbatim. The in-turn loop advances nodes within the same turn (multi-loop closed loop); goal-side auto-pauses cascade to the graph at a single chokepoint; node goals are armed with the remaining graph budget so mid-node overruns trip graph-wide. Gated by KIGI_GRAPH=1 (default off) + the goal harness. State persists to <session_dir>/graph/state.json with a clear-tombstone; per-version immutable baselines and per-node artifact archives live under graph/<graph_id>/. Restore demotes Active->UserPaused and Running->Ready (verifier-gated re-run). Review hardening (adversarial multi-agent pass, 24 confirmed findings fixed): the goal-inactive loop break now consults the graph seam (mid-turn classifier-disabled completions can no longer strand an Active graph), the pause cascade fires even when no node goal is in flight (cancel during planning), node bookkeeping precedes the long setup await, /graph clear only resets the engine it owns, budget trips terminally fail the in-flight node, and the pause transitions in /goal pause + /graph pause no longer hide inside debug_assert! (a release-build no-op inherited from upstream). Tests: 4908 kigi-shell lib tests green, including a serial 4-node closed-loop e2e, restore/resume, cascade, mutual-exclusion, planning-retry, persistence round-trip + tombstone, and status rendering.
1887 lines
82 KiB
Rust
1887 lines
82 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(),
|
|
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_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 = 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),
|
|
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,
|
|
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;
|
|
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
|
|
);
|
|
}
|
|
}
|