#![allow(clippy::await_holding_refcell_ref)] #![allow(clippy::arc_with_non_send_sync)] //! Session actor implementation for the MVP ACP agent. //! //! Each session runs as an actor with its own chat history and tool context. //! The agent owns the client connection and routes commands and events via //! channels: //! - Agent → Session: `SessionCommand` (prompt, cancel, shutdown) //! - Session → Client: `session_notification` via a shared gateway handle //! use super::commands::{ PromptCompletionKind, PromptTurnOk, PromptTurnResult, SessionCommand, ok_end_turn, }; use super::handle::SessionHandle; use super::notifications::NotificationSender; use crate::agent::update_chunk_merge::{BufferingSettings, ReplayBuffer}; use crate::extensions::notification::SessionUpdate as XaiSessionUpdate; use crate::extensions::notification::{ RetryState, SessionNotification as XaiSessionNotification, is_reauthable_failure, }; use crate::sampling::error::map_sampling_err_to_acp; use crate::sampling::types::{ChatRequestMessage, ToolCallResponse, ToolDefinition}; use crate::sampling::{ ContentPart, ConversationItem, ConversationRequest, ConversationResponse, SamplingError, SyntheticReason, ToolSpec, conversation_truncate_for_prompt, }; use crate::session::ClientFsConfig; use crate::session::feedback_manager::{FeedbackManager, FeedbackManagerConfig}; use crate::session::fs_watch::{self, git_head_dedup_key}; use crate::session::info::Info as SessionInfo; use crate::session::mcp_servers::McpInitStrategy; use crate::session::mcp_servers::McpMetaConfigMap; use crate::session::mcp_servers::McpState; use crate::session::mcp_servers::OauthInteractivity; use crate::session::mcp_servers::build_pending_clients; use crate::session::mcp_servers::mcp_server_name; use crate::session::mcp_servers::mcp_target_str; use crate::session::mcp_servers::mcp_transport_str; use crate::session::mcp_servers::parse_mcp_tool_name; use crate::session::persistence::{ PersistenceContentChunk, PersistenceHandle, PersistenceMsg, get_prompt_file_path, }; use crate::session::plan_mode::PromptMode; use crate::session::prompt_parser::parse_prompt_with_skills; use crate::session::replay_events::{SessionEvent, SessionNotification}; use crate::session::result::ExtMethodResult; use crate::session::signals::{SessionSignalsHandle, TurnDeltaSnapshot}; use crate::session::slash_commands::{self, BuiltinAction, SlashCommandOutcome}; use crate::session::storage::SessionUpdate; use crate::session::user_message::extract_user_query; use crate::session::user_message::{construct_user_message, construct_user_message_minimal}; use crate::terminal::{DEFAULT_TIMEOUT, TerminalRunRequest}; use crate::tools::ToolContext; use agent_client_protocol as acp; use agent_client_protocol::ContentBlock; use kigi_acp_lib::AcpAgentGatewaySender as GatewaySender; use kigi_agent::AgentDefinition; use kigi_agent::prompt::agents_md::LEGACY_AGENTS_MD_REMINDER_PREFIX; use kigi_agent::prompt::skills::SkillsConfig; use kigi_sampler::SamplerConfig as SamplingConfig; use kigi_sampling_types::truncate_bytes; use kigi_tools::computer::local::LocalTerminalBackend; use kigi_tools::implementations::BashToolInput; use kigi_tools::implementations::kigi::web_fetch::WebFetchConfig; use kigi_tools::types::ToolInput; use kigi_tools::types::compat::CompatConfig; use kigi_tools::types::output::{ BashOutput, ReadFileOutput, ToolOutput as ToolsToolOutput, ToolRunResult, }; use kigi_workspace::file_system::CodebaseIndexManager; use kigi_workspace::permission::{ AccessKind, ClientType, Decision, PermissionEvent, PermissionHandle, }; use kigi_workspace::session::file_state::{FileStateHandle, FileStateTracker}; use parking_lot::Mutex; use serde_json::json; use std::collections::{HashMap, VecDeque}; use std::path::Path; use std::sync::Arc; #[cfg(test)] use std::sync::OnceLock; use tokio::sync::{Mutex as TokioMutex, mpsc, oneshot}; use tokio::time::{Duration, sleep}; use tokio_retry::strategy::ExponentialBackoff; const SESSION_LOG: &str = "xai_session"; #[path = "compaction.rs"] mod compaction; #[path = "compaction_segments.rs"] mod compaction_segments; #[path = "acp_session_impl/types.rs"] mod types; pub(crate) use types::*; pub use types::{TodoGateDecision, TodoGateReason}; #[path = "acp_session_impl/goal.rs"] mod goal; #[path = "acp_session_impl/graph.rs"] mod graph; #[path = "acp_session_impl/interjection.rs"] mod interjection; #[path = "acp_session_impl/tool_calls.rs"] mod tool_calls; #[path = "acp_session_impl/turn.rs"] mod turn; pub(crate) use interjection::*; #[path = "acp_session_impl/laziness.rs"] mod laziness; pub(crate) use laziness::*; #[path = "acp_session_impl/hooks_plugins.rs"] mod hooks_plugins; #[path = "acp_session_impl/mcp.rs"] mod mcp; #[path = "acp_session_impl/model_switch.rs"] mod model_switch; #[path = "acp_session_impl/prompt_queue.rs"] mod prompt_queue; #[path = "acp_session_impl/slash_exec.rs"] mod slash_exec; use super::PromptOrigin; use super::acp_types; use super::chat_persistence; use super::compaction_config; use super::helpers; use super::memory_state; use super::telemetry; #[path = "acp_session_impl/prompt_build.rs"] mod prompt_build; use prompt_build::*; #[path = "acp_session_impl/session_mode.rs"] mod session_mode; use session_mode::*; #[path = "acp_session_impl/sampler_turn.rs"] mod sampler_turn; use sampler_turn::*; #[path = "acp_session_impl/tool_dispatch.rs"] mod tool_dispatch; use tool_dispatch::*; #[path = "acp_session_impl/mcp_snapshot.rs"] mod mcp_snapshot; use mcp_snapshot::*; #[path = "acp_session_impl/tasks_cancel.rs"] mod tasks_cancel; use tasks_cancel::*; #[path = "acp_session_impl/reminders.rs"] mod reminders; use reminders::*; pub use reminders::{CollectedTodoGateInput, TodoGateInput, evaluate_todo_gate}; #[path = "acp_session_impl/laziness_classifier.rs"] mod laziness_classifier; pub(crate) use laziness_classifier::*; #[path = "acp_session_impl/notification_drain.rs"] mod notification_drain; use notification_drain::*; #[path = "acp_session_impl/extensions.rs"] mod extensions; use extensions::*; #[path = "acp_session_impl/memory_dream.rs"] mod memory_dream; use memory_dream::*; #[path = "acp_session_impl/goal_support.rs"] mod goal_support; pub(crate) use goal_support::*; #[path = "acp_session_impl/hook_dispatch.rs"] mod hook_dispatch; use hook_dispatch::*; #[path = "acp_session_impl/recap.rs"] mod recap; #[path = "acp_session_impl/rewind.rs"] mod rewind; #[path = "acp_session_impl/run_loop.rs"] mod run_loop; #[path = "acp_session_impl/session_setup.rs"] mod session_setup; #[path = "acp_session_impl/turn_end.rs"] mod turn_end; #[path = "acp_session_impl/updates.rs"] mod updates; use run_loop::*; #[path = "acp_session_impl/spawn.rs"] mod spawn; use super::acp_types::*; pub use spawn::SessionThread; pub(crate) use spawn::*; /// Client-registered hook gates (the `kigi/hooks/run` reverse request). mod hooks; pub(crate) struct InputItem { pub(crate) prompt_id: String, pub(crate) prompt_blocks: Vec, pub(crate) prompt_mode: PromptMode, /// Optional client identifier from the prompt request meta (overrides session-level one) pub(crate) client_identifier: Option, /// See [`SessionCommand::Prompt::screen_mode`]. Telemetry-only. pub(crate) screen_mode: Option, /// See [`SessionCommand::Prompt::verbatim`]. pub(crate) verbatim: bool, pub(crate) json_schema: Option, /// Who originated this prompt — user or auto-wake system. pub(crate) origin: super::PromptOrigin, pub(crate) respond_to: oneshot::Sender, /// Fired after the user message is in chat history and a persistence flush /// barrier has completed (see `SessionCommand::Prompt::persist_ack`). pub(crate) persist_ack: Option>, /// Server-authoritative prompt-queue metadata. `Some` for /// user-originated prompts (they appear in the shared queue); `None` for /// synthetic / system inputs (auto-wake, nudges, notification drains). pub(crate) queue_meta: Option, /// Whether this prompt entered via the send-now path (explicit, derived /// during a blocking wait, or an interjection fallback). Send-now inserts /// land behind earlier still-queued send-now prompts so stacked sends /// (e.g. during a goal turn, which promotes but never cancels) run FIFO. pub(crate) send_now: bool, } use crate::session::commands::{NotificationPriority, NotificationSource}; /// Resolved tool names for goal-mode prompts. /// /// Built by [`SessionActor::resolve_goal_tool_names()`] to avoid /// duplicating `tool_for_kind()` calls across goal functions. struct GoalToolNames { goal: String, task: String, todo: String, } /// Shared body of the goal-mode system reminder. /// /// Loaded once at compile time. Used by both `setup_goal` (initial /// `/goal `) and `resume_goal` (`/goal resume`). /// Placeholders are uppercase to avoid collision with the literal /// `{...}` content in the prompt (e.g. JSON-ish call examples). pub(super) const GOAL_TASK_DISCIPLINE_TEMPLATE: &str = include_str!("templates/goal_task_discipline.md"); pub(super) const GOAL_RULES_TEMPLATE: &str = include_str!("templates/goal_rules.md"); /// Plan-aware preamble folded into the goal-rules block when the planner /// is enabled and a plan exists. Empty on the legacy path. const GOAL_PLAN_BLOCK_TEMPLATE: &str = include_str!("templates/goal_plan_block.md"); /// Body of the per-turn directive continuation nudge injected when an /// active goal is still running. Loaded once at compile time. Carries /// the live token count, the inlined next concrete step, and the /// proactive-testing reminder. Substituted via /// [`render_goal_continuation_directive`]; placeholders are lowercase /// because the template carries no literal JSON-ish `{...}` content /// that would collide (`goal_rules.md` keeps uppercase placeholders /// because it embeds verbatim user prose that may contain `{...}`). /// /// This template prints only `Tokens: N` (not a used/budget/remaining /// breakdown). A `/goal … --budget N` cap IS enforced at the turn-end /// continuation gate (terminal `BudgetLimited`), but the remaining-budget /// line is not rendered into this nudge. pub(super) const GOAL_CONTINUATION_DIRECTIVE_TEMPLATE: &str = include_str!("templates/goal_continuation_directive.md"); /// Built continuation directive plus the optional premature-stop pattern that /// the caller emits when it actually continues. Produced by /// [`SessionActor::prepare_goal_continuation`]. struct GoalContinuationPlan { directive: String, stop_pattern: Option<&'static str>, /// Recommendation embedded in `directive`, if any; handed to /// `consume_strategist_note` (compare-and-clear) only once the /// directive is committed for delivery. strategy_rec: Option, } /// Task scheduling state — the only fields that remain behind `TokioMutex`. /// /// All chat state (conversation, tokens, timing, prompt_index, prompt_texts, /// agent_edited_paths, last_compaction_prompt_index, sampling_config) has been /// fully migrated to `ChatStateActor` via `chat_state_handle`. /// Credentials (api_key, optional extra access key, client_version) live in /// the `credentials` sync mutex on `SessionActor`. pub(crate) struct State { pub(crate) running_task: Option, pub(crate) pending_inputs: VecDeque, pub(crate) pending_notifications: Vec, /// When true, notifications are buffered but not drained until the next /// user-initiated prompt arrives. Set on cancel, cleared on user Prompt. pub(crate) notifications_suppressed: bool, /// Active prompt is still rewindable until the first outbound prompt-scoped /// event is emitted. pub(crate) rewindable: bool, /// Layer-3 LazinessDetector: number of `` nudges /// injected so far in this (session, model) pair. Reset to 0 by /// the actor's main `select!` loop when its `model_switch_rx` /// watch channel fires — see the `model_switch_rx.changed()` arm /// in `run_session`. The cap is therefore per-(session, model): /// switching models is a deliberate user action that resets /// expectations. pub(crate) nudges_used_this_session: u32, } impl State { pub(crate) fn clear_pending_notifications(&mut self) { self.pending_notifications.clear(); } /// Prompt id of the in-flight turn, if any. This — not /// `current_prompt_id` / `is_running_prompt` — is the running-turn /// identity for queue sweeps: `running_task` lives under the same lock as /// `pending_inputs`, while `handle_completion` clears `current_prompt_id` /// before taking this lock, so only `running_task` is race-free here. pub(crate) fn running_prompt_id(&self) -> Option<&str> { self.running_task.as_ref().map(|t| t.prompt_id.as_str()) } /// Sweep `pending_inputs`, removing entries matching `drop_if` EXCEPT the /// running turn's own slot, and return the removed items (callers harvest /// them for telemetry counts / `auto_wake_delivered` un-marks). /// /// Returned items still carry live `respond_to` senders that this helper /// does NOT resolve — dropping them unfulfilled is correct only for /// synthetic items (no client RPC awaits them, the current callers); a /// caller whose predicate can match user-originated items must resolve /// each returned item (see `respond_removed_queued_prompt`) or the /// client's `session/prompt` hangs and fails spuriously. /// /// The guard is the safety invariant every sweep must inherit: the /// in-flight turn stays at the queue front until `handle_completion` or a /// cancel pops it, and an auto-wake turn's reminder makes the model poll /// the very task that woke it — so a sweep's predicate can match the /// running turn's own slot. Deleting it shifts a queued user prompt to /// index 0, where `cancel_running_task`'s resolve-front rule destroys it /// (the message never runs and is lost from history). Pid-match, not /// index 0: it protects exactly the true running slot even while idle or /// if the queue is already desynced from the front-is-running invariant. pub(crate) fn sweep_pending_inputs( &mut self, drop_if: impl Fn(&InputItem) -> bool, ) -> Vec { let running_pid = self.running_task.as_ref().map(|t| t.prompt_id.clone()); let mut dropped = Vec::new(); let mut kept = VecDeque::with_capacity(self.pending_inputs.len()); for item in std::mem::take(&mut self.pending_inputs) { if running_pid.as_deref() != Some(item.prompt_id.as_str()) && drop_if(&item) { dropped.push(item); } else { kept.push_back(item); } } self.pending_inputs = kept; dropped } } /// Canonical "session is idle and safe to inject a synthetic turn" /// predicate. The post-turn idle consumers — `maybe_drain_notifications` /// (notification batching), `maybe_fire_laziness_check` (Layer 3 classifier), /// and `arm_idle_notification` (idle-notification debounce) — all consult this /// so they share one definition of idleness, with no drift between them. /// /// Returns `true` exactly when: no turn is running, no user prompt is /// queued, and notifications haven't been suppressed by a cancel. pub(crate) fn is_session_idle_for_injection(state: &State) -> bool { state.running_task.is_none() && state.pending_inputs.is_empty() && !state.notifications_suppressed } /// Predicate behind `SessionCommand::IsBusy`: the session has work in flight /// when a turn is running **or** inputs are queued. Consulted by the leader's /// idle-unload decision on client disconnect. Kept as a free function so /// it can be unit-tested directly against a `State` without spawning a full /// actor + leader. pub(crate) fn state_is_busy(state: &State) -> bool { state.running_task.is_some() || !state.pending_inputs.is_empty() } use crate::auth::AuthManager; /// Data carried from prepare_tool_call → dispatch_tool → finalize. #[derive(Debug, Clone)] pub(crate) struct PreparedToolCall { /// The model's tool call ID (for tool_result matching). call_id: String, /// ACP-internal tool call ID. tool_call_id: acp::ToolCallId, /// The tool name as requested by the model. tool_name: String, /// The raw arguments string (for post_tool_use hook payload). raw_arguments: String, /// Parsed JSON arguments ready for bridge.call(). parsed_args: serde_json::Value, /// Model ID at time of call. model_id: String, /// Whether concatenated JSON recovery was used, and how many objects were found. concatenated_json_count: usize, /// Resolved target for meta-dispatch tools (`use_tool`, `CallMcpTool`); /// `None` for ordinary tools. See [`ToolInput::dispatch_target_name`]. dispatch_target_name: Option, /// Read-only per `ToolKind`; decides whether the call takes the per-file lock. is_read_only: bool, } impl PreparedToolCall { /// The tool name hooks see: the resolved dispatch target, else the wire name. /// The single source for the resolved name across the dispatch-phase hook /// events (PostToolUse / PostToolUseFailure) and their telemetry labels. pub(crate) fn hook_tool_name(&self) -> &str { self.dispatch_target_name .as_deref() .unwrap_or(&self.tool_name) } } #[cfg(test)] pub(crate) use crate::session::streaming_capture::STREAMING_CAPTURE_MAX_BYTES; pub(crate) use crate::session::streaming_capture::StreamingTurnCapture; /// Spawn-time metadata for a subagent, kept by `subagent_id` so the `SubagentStop` event /// (whose notification carries neither) can report the subagent's type and description. #[derive(Clone)] pub(crate) struct SubagentSpawnInfo { pub description: String, pub subagent_type: String, } /// Phase 3: Post-flight handling after dispatch (inline in execute_tool_calls for now). pub(crate) struct SessionActor { pub(crate) session_info: SessionInfo, /// Shared live handle to the current ACP auth method. Normal sessions hold a /// clone of `MvpAgent::auth_method_id`, so a mid-session `/login` is picked /// up by the per-turn auth gate without re-spawning; subagents instead get a /// fresh, isolated handle seeded once at spawn (frozen for their lifetime). /// `None` until the agent has selected a method. pub(crate) auth_method_id: crate::agent::auth_method::SharedAuthMethodId, /// Memoized per-model auth facts, keyed by model id — see /// [`SessionActor::model_auth_facts`]. pub(crate) model_auth_facts: std::cell::RefCell>, /// 401-attribution callback. Joined with the bearer the /// sampler sends on the wire to emit an `auth 401 attribution` /// event at each of the six `OaiCompatClient` 401 arms in /// `kigi-sampler`. Threaded into every `SamplerConfig` /// reconstructed by `reconstruct_full_config`. `None` when the /// session was spawned without an `AuthManager` (BYOK direct /// mode, test fixtures). pub(crate) attribution_callback: Option, /// Auth manager. Owns the token refresher internally (via /// `configure_refresher()`) and is also used for non-sampler /// 401 attribution sites: the sampler-side path goes through /// `attribution_callback` above, while the idle-resume model /// refresh in this file calls /// `crate::auth::attribution::record_auth_401` directly using /// this handle. `None` for tests / BYOK that don't need refresh /// or the attribution emit. pub(crate) auth_manager: Option>, pub(crate) state: TokioMutex, /// Notification transport: gateway, persistence channel, replay buffer. pub(crate) notifications: NotificationSender, pub(crate) permissions: PermissionHandle, pub(crate) tool_context: ToolContext, /// Managed Read-deny glob patterns, resolved once at construction and /// (re-)injected into the ToolBridge so the Grep tool excludes policy-forbidden /// paths. Actor-retained so session setup and harness-rebuild share one source /// of truth (mirrors `goal_update_tx`), rather than re-resolving the config. pub(crate) deny_read_globs: Vec, /// Consolidated MCP state (configs, clients, init status) protected by a single lock. /// This ensures atomicity when updating configs or checking initialization status. pub(crate) mcp_state: Arc>, /// MCP initialization strategy pub(crate) mcp_strategy: McpInitStrategy, /// Actor-based chat state handle — manages conversation, tokens, timing, and persistence. /// Also stores credentials (api_key, optional extra access key, /// client_version) opaquely. pub(crate) chat_state_handle: kigi_chat_state::ChatStateHandle, /// Current running prompt/turn id, shared with SessionHandle. pub(crate) current_prompt_id: std::sync::Arc>>, /// Open blocking reverse-requests (permission / question / plan-approval), /// keyed by `tool_call_id`. Shared with `SessionHandle` so the roster can /// read it synchronously to surface `NeedsInput`. Mutated by /// `PendingInteractionGuard` at each reverse-request site. Never persisted. pub(crate) pending_interactions: crate::session::pending_interaction::PendingInteractions, /// Gates product analytics, not trace uploads. Resolved at spawn as pub(crate) supports_backend_search: std::cell::Cell, pub(crate) compactions_remaining: std::cell::Cell>, pub(crate) compaction_at_tokens: std::cell::Cell>, /// Server-side doom-loop check policy, resolved once at spawn by /// `Config::resolve_doom_loop_recovery`; `None` = disabled. /// `reconstruct_full_config` threads it into the sampler config, and the /// sampler itself sends the matching `x-kigi-doom-loop-check` header. pub(crate) doom_loop_recovery: Option, /// Telemetry-only per-turn doom-loop recovery tally (attempts, whether a /// budget-spent accept happened, tightest trigger label). Accumulated by /// the event drainer, taken at turn end for the per-turn analytics event. pub(crate) doom_loop_turn_tally: parking_lot::Mutex, /// File state tracker for rewind functionality pub(crate) file_state_tracker: Arc, /// Last prompt text before the most recent rewind. /// When set, the next `prompt()` compares its text to distinguish /// regeneration (same text) from edit-and-retry (different text). pub(crate) rewind_pending_prompt: std::sync::Mutex>, /// Startup hints for the session: currently responsible for customizing the user message prefix and the git status mode (fast no untracked for non-interactive mode) pub(crate) startup_hints: StartupHints, /// Verbatim mirror-fork override: when `Some`, every turn sends this exact /// parent tool schema instead of the locally-built toolset, keeping the /// child's request prefix byte-identical to the parent for radix cache reuse. /// `None` for all non-fork (and summarized-fork) sessions. pub(crate) forked_tool_override: Option>, /// Compaction configuration and runtime state. pub(crate) compaction: super::compaction_config::CompactionConfig, /// Memory subsystem: storage, flush config, injection state, telemetry. pub(crate) memory: super::memory_state::SessionMemory, /// Telemetry counters for session summary. pub(crate) session_start: std::time::Instant, /// Per-chunk idle timeout for inference streaming. If no SSE chunk is received /// within this duration, the stream is aborted with a non-retryable error. /// Resolved at construction: per-model config.toml → remote settings → 300s default. pub(crate) inference_idle_timeout: Duration, pub(crate) max_retries: u32, /// Maximum tool-use turns before the session stops. `None` = unlimited. pub(crate) max_turns: Option, /// Pending mid-turn interjections from the user (Ctrl+Enter). /// Pushed by `SessionCommand::Interject` handler, drained at safe /// points in `process_conversation_turn`. Internally synchronized. pub(crate) pending_interjections: InterjectionBuffer, /// Skill-announcement reminders that arrived while a turn was running, /// flushed at the same safe points as `pending_interjections` plus on /// cancel/idle. The flush also delivers the plan tracker's buffered /// mid-turn activation reminder (see `activate_plan_mode_mid_turn`). pub(crate) pending_skill_reminders: Mutex>, /// Idle flush timeout: `None` = disabled, `Some(duration)` = flush after inactivity. pub(crate) idle_flush_timeout: Option, /// Periodic dream check interval: `None` = disabled. pub(crate) dream_check_timeout: Option, /// Conversation length at last idle flush — skip if unchanged (no new messages). pub(crate) last_idle_flush_conversation_len: std::sync::atomic::AtomicUsize, /// Internal event queue for actor-owned replay buffering and flush barriers. pub(crate) event_tx: mpsc::UnboundedSender, /// Buffering settings captured at session creation. The concrete ReplayBuffer /// is owned by `run_session()`. pub(crate) buffering_settings: Option, /// Client identifier for telemetry - passed from the MvpAgent (extracted from initialize meta) pub(crate) client_identifier: Option, /// Origin client for User-Agent on sampling requests. pub(crate) origin_client: Option, /// Feedback manager for signal tracking and feedback request heuristics pub(crate) feedback_manager: Arc, /// The fully-built Agent: owns the ToolBridge, system prompt, policies, /// and the AgentDefinition. Replaces the old `tool_bridge` + `agent_definition` fields. /// Wrapped in `RefCell` for mid-session mutation (skill refresh, prompt regen). /// Safe: session actor is single-threaded (LocalSet), no concurrent access. pub(crate) agent: std::cell::RefCell, /// Dedup slot for `kigi/git_head_changed`, shared with the fs-watch /// `GitHead` consumer (see `git_head_dedup_key`). pub(crate) last_reported_branch: Arc>>, /// Client opted into `kigi/gitHeadChanged`. When false (headless/SDK), /// `maybe_notify_git_branch` no-ops — no git subprocess. git_head_enabled: bool, /// Shared models manager for etag-triggered refresh from response headers. pub(crate) models_manager: crate::agent::models::ModelsManager, /// Stable display path for forked sessions (original project path). /// /// Used by `build_user_message_prefix` (user-message `Workspace Path`), /// `PathRewriter` (tool result path sanitization), and hunk tracker /// (client-facing diff paths). The system prompt's `Workspace Path` is /// set at build time via `AgentBuilder::with_prompt_working_directory()`. /// /// Set once at session spawn from the `prompt_display_cwd` parameter /// (e.g. for forked sessions that should display the original project /// path). Uses `OnceLock` for lock-free reads, set-once semantics, and /// `&self` mutability (SessionActor is behind `Arc`). pub(crate) display_cwd: std::sync::OnceLock, /// The active agent type for this session. Initialized from the /// `AgentDefinition` at spawn, updated when the session mode changes /// via `handle_session_mode()`. Used by the model-switch guard to /// determine whether a model's `agent_type` is compatible with the /// current session. pub(crate) active_agent_type: parking_lot::Mutex>, /// Live gate shared with the notification bridge (see /// `NotificationBridgeConfig::queue_exit_reminder_on_approved_exit`). /// Seeded at spawn from the agent definition's harness; refreshed by /// `handle_rebuild_agent_for_definition` so the bridge always agrees /// with the live harness gate. pub(crate) queue_exit_reminder_on_approved_exit: std::sync::Arc, /// First skill the current prompt activated via its slash-skill path, /// recorded as `skill.name` on the turn span. Reset at the start of each /// prompt (`handle_prompt`), so it never leaks across turns. pub(crate) active_skill: parking_lot::Mutex>, /// Canonical session mode last set via ACP `session/set_mode`. /// Used as the fallback start prompt mode when prompt request metadata /// does not explicitly provide one. pub(crate) current_prompt_mode: Arc>, /// Prompt mode captured at the start of the current turn. Set once in /// `handle_prompt` and never modified during the turn. Used for /// `start_prompt_mode` telemetry. pub(crate) turn_start_prompt_mode: parking_lot::Mutex, /// Effective mode of the currently running turn. Set at turn start from /// the prompt mode parameter, then updated only by agent-initiated tool /// calls (`EnterPlanMode` / `ExitPlanMode`). NOT affected by /// `session/set_mode` (which only changes the next turn's start mode). /// Read at turn end for `end_prompt_mode` telemetry. pub(crate) turn_prompt_mode: Arc>, /// Plan mode lifecycle tracker. Session-scoped dynamic state (not part /// of `AgentDefinition`). All plan mode logic lives in `plan_mode.rs`; /// the session actor just calls into the tracker at the appropriate points. /// `Arc`-shared with the notification bridge so `PlanModeEntered` / /// `PlanModeExited` tool notifications can transition state directly. pub(crate) plan_mode: Arc>, /// Whether goal mode (`/goal`) is enabled for this session (feature flag). pub(crate) goal_enabled: bool, /// `goal_enabled` && `update_goal` in toolset; refreshed with command availability. goal_harness_enabled: std::sync::atomic::AtomicBool, /// One-shot: auto-pause persisted Active goal when harness is unavailable. goal_harness_availability_reconciled: std::sync::atomic::AtomicBool, /// Goal mode orchestration tracker. Session-scoped state for the /// Design-Execute-Verify loop. Modeled after `plan_mode` above. pub(crate) goal_tracker: Arc>, /// Whether graph mode (`/graph`) is enabled for this session (feature /// flag `KIGI_GRAPH`). Availability additionally requires the goal /// harness — graph nodes execute as goals. pub(crate) graph_enabled: bool, /// Graph mode orchestration tracker: the deterministic DAG scheduler /// layered over the goal engine. Modeled after `goal_tracker` above; /// all graph state logic lives in `graph_tracker.rs`. pub(crate) graph_tracker: Arc>, /// `task_id`s of background tasks (and monitors) that originated during /// the goal turn — either spawned by the goal model itself or reparented /// from a harness verifier/planner subagent on its exit. Their late /// auto-wake completions are dropped by [`Self::maybe_drain_notifications`] /// regardless of the goal's current status, so a leftover dev/verification /// server that completes after the run ended (Blocked / paused / cleared) /// cannot wake the idle parent. Reset when a new goal starts or the goal /// is cleared. pub(crate) goal_turn_task_ids: parking_lot::Mutex>, /// Consecutive non-completing (cancelled/errored) goal-mode turns while /// the goal is `Active`. Reset to 0 on a successful turn or on user /// `/goal resume`. Auto-pauses the goal with `GoalPauseReason::BackOff` /// once the counter reaches [`GOAL_CONTINUATION_BACKOFF_THRESHOLD`]. /// In-memory only — session restart is itself a reset. pub(crate) goal_continuation_streak: std::sync::atomic::AtomicU32, /// Consecutive blocked attempts from the model. Reset on successful /// turn completion, goal completion, or goal resume. Only after 3 /// consecutive blocked attempts does the goal actually pause. pub(crate) goal_blocked_streak: std::sync::atomic::AtomicU32, /// Receiver for goal-update envelopes from the `update_goal` tool. /// Wrapped in `Option` so the drainer task can `.take()` it at /// session start; tests put a fresh receiver back via /// `seed_channel` helpers. pub(crate) goal_update_rx: std::cell::RefCell< Option< tokio::sync::mpsc::UnboundedReceiver< kigi_tools::implementations::kigi::update_goal::UpdateGoalEnvelope, >, >, >, /// Sender half of the goal-update channel, retained so a mid-session /// harness rebuild can re-register the `GoalUpdateHandle` on the fresh, /// empty ToolBridge. The `rx` half is owned by the drainer task (see /// `goal_update_rx`). pub(crate) goal_update_tx: tokio::sync::mpsc::UnboundedSender< kigi_tools::implementations::kigi::update_goal::UpdateGoalEnvelope, >, /// Resolved master kill-switch for the verification stage (the /// adversarial skeptic panel). `false` short-circuits /// `drain_goal_updates` to plain `tracker.complete()` + ack /// `CompletedWithoutClassifier` and BYPASSES the verification /// stage. `true` enables the skeptic panel with all guards /// (Active-only, mid-turn defer, in-flight re-entry, max-runs /// cap). The field name is preserved to keep /// the env / remote / config wire contract stable. pub(crate) goal_classifier_enabled: bool, /// Master switch for the goal planner subagent. pub(crate) goal_planner_enabled: bool, /// Master switch for the one-shot goal summarizer (the closing /// "what was accomplished" summary on a verified achievement). /// Cached at actor construction (mirrors `goal_classifier_enabled`); /// absent remote setting tracks goal mode, `Some(false)` is a kill-switch. pub(crate) goal_summary_enabled: bool, /// Resolved skeptic count for the verification stage. /// Cached at actor construction (mirrors `goal_classifier_enabled`) /// and threaded into [`Self::run_verification_stage_for_drain`]. /// Default `GOAL_VERIFIER_SKEPTIC_COUNT`; clamped to /// `[GOAL_VERIFIER_SKEPTIC_MIN, GOAL_VERIFIER_SKEPTIC_MAX]` by the /// resolver. pub(crate) goal_verifier_skeptic_count: u32, /// Resolved per-role `/goal` model selection (planner / strategist /// single pairs + the ordered skeptic pool), with the kill-switch /// already applied. Cached at actor construction from remote settings; /// `Default` (all `InheritCurrent`, empty pool) reproduces today's /// behavior. Consumed by the per-role spawn wiring. pub(crate) goal_role_models: GoalRoleModelConfig, /// Kill-switch (`KIGI_GOAL_USE_CURRENT_MODEL_ONLY` / `[features] /// goal_use_current_model_only`) resolved at actor build. When `true`, /// every `/goal` role inherits the current model. `goal_role_models` /// already reflects it (planner/strategist `InheritCurrent`, empty pool), /// but the skeptic panel also checks this flag directly so a /// previously-frozen `skeptic_model_assignment` is overridden too — an /// instant rollback even for an already-frozen goal. pub(crate) goal_use_current_model_only: bool, /// Resolved per-goal classifier run cap (number of /// `update_goal(completed: true)` rejections before the goal /// auto-pauses via `BackOff`). Cached at actor construction like /// `goal_verifier_skeptic_count`. Default /// `GOAL_CLASSIFIER_MAX_RUNS_DEFAULT`; floored at /// `GOAL_CLASSIFIER_MAX_RUNS_MIN` with no upper ceiling by the /// resolver. Read by [`Self::resolve_goal_classifier_policy`]. pub(crate) goal_classifier_max_runs: u32, /// Resolved N for the stall-triggered strategist: it fires every N /// consecutive `NotAchieved` verifications (and again at 2N, 3N, …). /// Cached at actor construction. Default `max(1, goal_classifier_max_runs /// / 2)`; clamped to `>= 1` by the resolver. Read by the strategist /// trigger in `apply_classifier_outcome`. pub(crate) goal_strategist_every: u32, /// Resolved refuted-goal round count before the continuation directive /// escalates to a forceful "re-verify now" block; cached at actor /// construction. Read by [`Self::prepare_goal_continuation`]. pub(crate) goal_reverify_after: u32, /// Set on session load once `maybe_reconcile_active_goal_without_plan` /// has run so subsequent prompt-flow ticks don't repeat the /// pause-on-load check. pub(crate) goal_plan_reconciled: std::sync::atomic::AtomicBool, /// FIFO of mid-turn-deferred `completed: true` inputs. The /// envelope's ack was resolved with `DeferredToTurnEnd` at defer /// time; only the input is parked here for the TurnEnd drain to /// run through the verification stage. pub(crate) pending_classifier_completions: parking_lot::Mutex< VecDeque, >, /// Per-session re-entry guard for the verification stage. Set with /// `compare_exchange(false, true)` at fire-entry and cleared on /// result. A second `completed: true` that races in while the /// flag is set short-circuits through /// [`Self::account_not_achieved_without_sampler`]. pub(crate) goal_classifier_in_flight: std::sync::atomic::AtomicBool, /// Original client-provided MCP servers from session creation. /// Retained for re-merge during plugin reload. pub(crate) initial_client_mcp_servers: Vec, /// Shared MCP tool metadata for the BM25 search index. Updated after MCP init. pub(crate) tool_metadata_snapshot: Arc>, /// Tracks which servers have been announced via system-reminder, for /// change detection. Maps server_name -> (tool_count, description_hash). pub(crate) mcp_announced_servers: Mutex>, /// Controls whether MCP server reminders inject only changes (Delta) /// or the full server list (Full). Read from `MCP_REMINDER_MODE` env var. pub(crate) mcp_reminder_mode: McpReminderMode, /// Set when the MCP server set changes and a reminder needs injection. /// Cleared by `maybe_inject_mcp_reminder` after injecting. pub(crate) mcp_reminder_dirty: Arc, pub(crate) mcp_connecting_reminder_injected: std::cell::Cell, /// Wakes waiters when MCP background handshakes finish and /// `initializing_servers` is cleared. Used by /// `wait_for_mcp_templated_prefix_ready` to avoid polling. pub(crate) mcp_handshakes_done: Arc, /// Background-computed user-message prefix, injected before the first prompt. pub(crate) deferred_prefix: TaskSlot, /// Extensions to notify at turn and session lifecycle edges. Built once by `session_extension_registry` at actor construction and frozen after. pub(crate) extension_registry: kigi_agent_lifecycle::LocalExtensionRegistry, /// Local calendar date last surfaced to the model — either stamped into the /// `` prefix (at session start, compaction, or resume) or /// announced via a date-rollover ``. Drives /// [`SessionActor::maybe_inject_date_rollover_reminder`] (date /// rollover: tell the model the date advanced when a long session crosses /// local midnight, since the cached prefix isn't re-stamped per turn). The /// actor is single-threaded, so a `Cell` suffices. pub(crate) last_announced_local_date: std::cell::Cell, /// Prompt index when search_tool last ran. -1 = never. Used for turns_since_last_search. pub(crate) last_search_prompt_index: std::sync::atomic::AtomicI64, /// Timestamp (millis since epoch) of the last successful API request. /// Used to detect session resume after idle and proactively refresh model metadata. pub(crate) last_api_request_at: std::sync::atomic::AtomicI64, /// Hook registry for session lifecycle and tool event hooks. /// Loaded at session startup; can be updated mid-session via `/plugins reload`. /// `None` when no plugin registry was supplied at spawn time. /// Wrapped in `RefCell` for mid-session reload from `&self` methods. /// Safe: session actor is single-threaded (LocalSet), no concurrent access. pub(crate) hook_registry: std::cell::RefCell>>, /// Client hooks from `session/new` `_meta["kigi/hooks"]`; gated in /// [`crate::session::acp_session::hooks`]. `RefCell` so `load_session` reconnect can /// replace the set on the live actor (see `SessionCommand::SetClientHooks`). pub(crate) client_hooks: std::cell::RefCell, /// Resolved workspace root for hooks: git worktree root if in a git repo, /// otherwise session cwd. Used for hook child process cwd, envelope fields, /// and KIGI_WORKSPACE_ROOT env var. pub(crate) hook_resolved_workspace_root: String, /// The detected VCS kind for this session's workspace. pub(crate) vcs_kind: kigi_workspace::session::git::VcsKind, /// Errors from last hook config load (parse failures, etc.). pub(crate) hook_load_errors: std::cell::RefCell>, /// Plugin registry snapshot for this session. Updated on `/plugins reload`. /// `RefCell` for mid-session reload from `&self` methods. pub(crate) plugin_registry: std::cell::RefCell>>, /// Shared handle to the agent-level plugin registry. /// Used by `/plugins reload` to trigger a rebuild that new sessions see. pub(crate) plugin_registry_handle: Option, /// Centralized event tracking: event log, turn-end guard, active tool, /// doom loop terminate flag. All event-related state lives here. pub(crate) events: crate::session::events::EventTracker, /// Turn number captured at the start of each turn (before prompt index /// increment). Used by `ToolCallStarted` bridge emissions so they /// report the same turn number as `TurnStarted` / `TurnEnded`. pub(crate) current_turn_number: std::cell::Cell, /// Recap rate-limit watermark (`main_turns` of last finished recap; `0` = none). pub(crate) last_recap_main_turn: std::cell::Cell, /// True while a recap model call is in flight (auto or manual). Prevents /// concurrent `spawn_local` recaps from racing watermark restore. pub(crate) recap_in_flight: std::cell::Cell, /// Bumped on each real user prompt (queue accept + turn start); in-flight /// recap suppresses emit if this changes before commit. pub(crate) recap_epoch: std::cell::Cell, /// True while THIS session has a prompt turn in flight (RAII-guarded in /// `handle_prompt`, like `tool_context.is_turn_active` — which is the /// agent-wide coordinator flag shared by all sessions and so unusable /// for per-session decisions). `Arc` so it can be re-checked inside the /// chat-state actor's `RepairHistory` handler. pub(crate) session_turn_active: Arc, /// Per-turn accumulator for the model's streamed generations, populated by /// `handle_sampling_event` while the sampler is streaming. Each /// `SamplingEvent::Completed` discards the in-progress generation (committed /// to `afterStateHistory`) without wiping the capture, so a same-turn /// doomloop retry's earlier uncommitted generations are preserved. /// /// On user-cancel mid-stream, a sampler terminal failure (e.g. /// `MaxTokensTruncation`), or a doomloop, the consumer takes the capture via /// `SessionCommand::TakeStreamingCapture`, which `finalize_for_upload`s the /// uncommitted generations and uploads them as /// `{session_id}/turn_N/streaming_partial.json` for trace inspection. See /// [`crate::session::streaming_capture`]. /// /// **This is deliberately out-of-band from `chat_state`.** The partial /// is never returned by `BuildConversationRequest`, never pushed via /// `push_assistant_response`, and never sent to the model on subsequent /// turns. Trace-only, inspection-only. /// /// **Known tail race:** if a queued prompt's `StreamStarted` arrives at /// the actor's `select!` between cancel-time and `TakeStreamingCapture`, /// the live slot is reset with the new turn's prompt-id before the /// take. The take handler then sees a prompt-id mismatch and returns /// `None`, dropping the cancelled turn's `streaming_partial.json`. /// A `tracing::warn!` tripwire in the handler logs every occurrence so /// we can quantify the loss in production before investing in a stash. pub(crate) streaming_turn_capture: parking_lot::Mutex, /// Per-turn barrier that orders the streamed message against the turn's /// tool calls. /// /// The sampler's events (text/thought chunks) are emitted by a separate /// drainer task (`handle_sampling_event`), while the turn loop emits the /// canonical client `ToolCall` notifications itself after /// `run_turn_via_sampler` returns. Both call `send_update`, which allocates /// the process-global, monotonically-increasing `eventId` AT CALL TIME (see /// `generate_event_id`). Because the two run as distinct tasks on the /// session `LocalSet`, the tool call's `send_update` could interleave /// BETWEEN two still-draining text chunks — allocating an `eventId` mid /// message and splitting the assistant text around the tool call on every /// attached client (the eventId order is what clients render in). /// /// To keep all of a turn's `eventId`s in stream order, `run_turn_via_sampler` /// installs a sender here before submitting and awaits the receiver after the /// response arrives; the drainer fires it the moment it processes the /// terminal `SamplingEvent::Completed` (every text/thought chunk has been /// `send_update`d by then). `None` between turns. pub(crate) turn_stream_drained: parking_lot::Mutex>>, /// Handle to the per-session `kigi-sampler` actor. /// /// Live sessions get a real handle from `spawn_session_actor`; /// tests and other constructor sites use `SamplerHandle::noop()`. /// All inference flows through this handle. pub(crate) sampler_handle: kigi_sampler::SamplerHandle, /// Cached recipe for constructing this session's [`kigi_agent::Agent`]. /// /// Populated once at session spawn and then reused by /// `handle_rebuild_agent_for_definition` to build a fresh `Agent` /// (system prompt, [`kigi_tools::bridge::ToolBridge`], tool /// registry, tool name aliases, compaction policy, and reminder /// policy) when the user picks a model with a different /// `agent_type` before sending any user message. /// /// See [`crate::session::agent_rebuild`] for the canonical-construction /// invariant. pub(crate) rebuild_spec: Arc, /// Resolved vision model ID for auxiliary image processing. /// Populated from `Config.image_description_model` at spawn. pub(crate) image_description_model: String, /// Cache auxiliary image outputs by content and prompt fingerprint. pub(crate) image_describe_cache: Arc, /// [`SubagentSpawnInfo`] by `subagent_id`: inserted on `SubagentSpawned`, removed on /// `SubagentFinished`. pub(crate) subagent_spawn_info: parking_lot::Mutex>, /// Per-subagent token state keyed by `subagent_id`; sums into /// goal totals via [`Self::goal_tokens`]. pub(crate) subagent_token_records: parking_lot::Mutex>, pub(crate) workspace_ops: kigi_workspace::WorkspaceOps, /// Layer-3 LazinessDetector: monotonic counter bumped whenever a /// fresh (non-synthetic) user prompt arrives at the actor. /// `maybe_fire_laziness_check` snapshots the value at start and /// polls for changes in its idle-wait loop. /// /// **vs. `tokio::sync::Notify`** (the original design): /// generation-counter snapshot+compare avoids the stored-permit /// hazard. A `notify_one()` emitted before the classifier spawns /// would cause the spawn-later `.notified()` arm to fire /// immediately, aborting the classifier on the very first idle /// period after any real turn. An `AtomicU64` has no such hazard. /// /// **vs. `tokio::sync::watch::Sender`** (the mirror design /// used for `ModelsManager::model_switch_watch`): single-consumer /// cardinality here. The only reader of `user_input_generation` /// is the per-actor laziness task's snapshot+compare; no /// main-loop subscriber needs a wake-on-change for user input /// (the prompt handler is itself the *producer*, in the same /// task). `tokio::sync::watch::Sender` is internally lock-bearing /// (an `RwLock` per `tokio` source), so adopting it for this /// field would re-introduce a per-actor lock for a use case an /// `AtomicU64` already covers correctly. Model-switch differs /// because its main-loop arm DOES need a wakeup to zero the /// per-session nudge counter — the watch channel's `.changed()` /// is the right primitive there. pub(crate) user_input_generation: std::sync::atomic::AtomicU64, /// Session-scoped `--laziness-debug-log `. When `Some`, the /// Layer-3 classifier fires after every turn end (bypassing the /// idle wait, the per-model enable gate, and the nudge cap), and /// the full outcome is appended as a JSONL line to this file. /// Observation-only — no nudges are ever injected when this is /// `Some`. `Arc` because the path is immutable after /// session spawn; concurrent appends rely on `O_APPEND`'s atomic /// guarantee for writes under `PIPE_BUF` (JSONL lines fit). pub(crate) laziness_debug_log: Option>, } impl SessionActor { /// Get the signals handle for tracking session events. fn signals_handle(&self) -> SessionSignalsHandle { self.feedback_manager.signals_handle() } fn emit_event(&self, event: crate::session::events::Event) { self.events.emit(event); } fn emit_turn_ended( &self, outcome: crate::session::events::TurnOutcomeLabel, category: Option, context: Option, ) { self.events.emit_turn_ended(outcome, category, context); } /// Current model ID for OTLP span attributes. Reads from chat_state_handle /// so it always reflects the latest model override — no stale cached field. /// Returns "unknown" if no sampling config is set. async fn current_model_id(&self) -> String { self.chat_state_handle .get_sampling_config() .await .map(|c| c.model) .filter(|m| !m.is_empty()) .unwrap_or_else(|| "unknown".to_string()) } /// Build a hook run context for dispatching hook events. fn session_id_string(&self) -> String { self.session_info.id.0.to_string() } /// Send a before-turn hook via the local workspace channel. /// Fire-and-forget — failures are logged but do not interrupt the turn. async fn send_before_turn_event( &self, payload: kigi_tool_protocol::turn_hook::BeforeTurnPayload, ) { self.workspace_ops .on_before_turn(&self.session_id_string(), &payload) .await; } /// Send an after-turn hook via the local workspace channel. /// Fire-and-forget — failures are logged but do not interrupt the turn. async fn send_after_turn_event( &self, payload: kigi_tool_protocol::turn_hook::AfterTurnPayload, ) { self.workspace_ops .on_after_turn(&self.session_id_string(), &payload) .await; } /// Compute the live command availability snapshot for this session. /// /// Convenience wrapper that fetches the toolset and delegates to /// `build_command_availability`. Use this on the inbound resolve /// path; the outbound advertise path enumerates tools once and /// shares the slice across both calls (see /// `send_available_commands_update`). async fn command_availability(&self) -> slash_commands::CommandAvailability { let tool_names = self.registered_tool_names().await; let availability = self.build_command_availability(&tool_names); self.maybe_reconcile_active_goal_without_harness().await; self.maybe_reconcile_active_goal_without_plan().await; availability } /// Build the `CommandAvailability` snapshot from a precomputed slice /// of tool names plus the live session-scoped capability state. /// /// Single source of truth for the seven gate fields -- both /// `command_availability` (resolve path) and /// `send_available_commands_update` (advertise path) call this so /// the two paths can never drift. fn build_command_availability( &self, tool_names: &[String], ) -> slash_commands::CommandAvailability { use kigi_tools::implementations::memory::{MEMORY_GET_TOOL_NAME, MEMORY_SEARCH_TOOL_NAME}; let memory_read_registered = tool_names .iter() .any(|n| n == MEMORY_SEARCH_TOOL_NAME || n == MEMORY_GET_TOOL_NAME); let goal = self.sync_goal_harness_from_tools(tool_names); slash_commands::CommandAvailability { feedback: self.feedback_manager.is_enabled(), memory: self.memory.is_enabled() && memory_read_registered, memory_configured: self.memory.backend_params.is_some(), scheduler: tool_names .iter() .any(|n| n == kigi_tools::implementations::kigi::SCHEDULER_CREATE_TOOL_NAME), hooks: self.hook_registry.borrow().is_some(), plugins: self.plugin_registry.borrow().is_some(), goal, // Graph rides the goal harness: nodes execute as goals, so // `/graph` is only real when `/goal` is. graph: self.graph_enabled && goal, } } /// Names of every tool registered with the session's tool bridge. /// /// Async wrapper that fetches `tool_definitions()` and projects to /// the `function.name` field. Allocates one `Vec` per call; /// callers that need both gating and the wire payload should call /// once and pass the slice to `build_command_availability`. async fn registered_tool_names(&self) -> Vec { let bridge = self.agent.borrow().tool_bridge().clone(); bridge .tool_definitions() .await .into_iter() .map(|td| td.function.name) .collect() } /// Send visible text output to the TUI from a slash command. /// /// Uses `AgentMessageChunk` so the text appears in the conversation /// scrollback, then flushes the replay buffer to ensure delivery /// before the turn ends. async fn send_slash_command_output(&self, text: &str) { self.send_update( acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text( acp::TextContent::new(text.to_string()), ))), None, ) .await; if let Err(e) = crate::session::replay_events::flush_replay_actor(&self.event_tx).await { tracing::warn!(?e, "flush_replay_actor failed"); } } /// Send a feedback request notification to the client. async fn send_feedback_notification(&self, request: crate::session::feedback::FeedbackRequest) { self.send_xai_notification(XaiSessionUpdate::FeedbackRequest(request.into())) .await; } } impl SessionActor { /// Owned handle to the active `ToolBridge`. Used by async methods /// that need to drop the `RefCell::Ref` borrow before /// awaiting — `Arc::clone` is cheap, and an outstanding `Ref` /// across `.await` would panic if anything on the suspended path /// did `self.agent.borrow_mut()`. fn tool_bridge_handle(&self) -> Arc { Arc::clone(self.agent.borrow().tool_bridge()) } } const PROMPT_CONTEXT_FILENAME: &str = "prompt_context.json"; /// Persist the structured prompt context to `{session_dir}/prompt_context.json`. /// /// This is best-effort: failures are logged but do not block session creation. /// The saved JSON enables deterministic re-rendering, `kigi prompt --json` /// inspection, and post-hoc debugging of what went into a session's system prompt. fn save_prompt_context(session_info: &SessionInfo, prompt_context: &kigi_agent::PromptContext) { let dir = crate::session::persistence::session_dir(session_info); if let Err(e) = std::fs::create_dir_all(&dir) { tracing::warn!(?e, "failed to create session dir for prompt_context.json"); return; } let path = dir.join(PROMPT_CONTEXT_FILENAME); match serde_json::to_string_pretty(prompt_context) { Ok(json) => { if let Err(e) = std::fs::write(&path, json) { tracing::warn!(?e, "failed to write prompt_context.json"); } } Err(e) => { tracing::warn!(?e, "failed to serialize PromptContext"); } } } const SYSTEM_PROMPT_FILENAME: &str = "system_prompt.txt"; /// Synchronously and atomically rewrite `{session_dir}/chat_history.jsonl`. /// /// Serializes to a temp file then `rename`s over the target, matching the /// persistence actor's own crash-safety (a truncating in-place write can tear /// the file on crash / `ENOSPC`). Best-effort with a logged failure. /// /// Callers use this for a *synchronous* on-disk snapshot at spawn / initialize / /// agent-rebuild: `chat_state_handle.replace_conversation` persists the same /// content, but only after two async actor hops, so a reload that races the /// first prompt could otherwise read the bare pre-enrichment template. A /// distinct temp suffix (`.sync.tmp`) avoids clobbering the persistence actor's /// own `chat_history.jsonl.tmp`; whichever atomic `rename` lands last wins and /// the content is identical, so the two writers can never produce a torn file. fn persist_chat_history_jsonl_sync(session_info: &SessionInfo, conversation: &[ConversationItem]) { let dir = crate::session::persistence::session_dir(session_info); if let Err(e) = std::fs::create_dir_all(&dir) { tracing::warn!( session_id = % session_info.id.0, ? e, "persist_chat_history_jsonl_sync: failed to create session dir" ); return; } let final_path = dir.join("chat_history.jsonl"); let tmp_path = dir.join("chat_history.jsonl.sync.tmp"); let result = (|| -> std::io::Result<()> { use std::io::Write; let mut buf = Vec::new(); for item in conversation { serde_json::to_writer(&mut buf, item) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; buf.push(b'\n'); } std::fs::File::create(&tmp_path)?.write_all(&buf)?; std::fs::rename(&tmp_path, &final_path)?; Ok(()) })(); if let Err(e) = result { tracing::warn!( session_id = % session_info.id.0, ? e, "persist_chat_history_jsonl_sync: failed to persist chat_history.jsonl" ); let _ = std::fs::remove_file(&tmp_path); } } /// Persist the exact rendered system prompt to `{session_dir}/system_prompt.txt`. /// Should match the first System entry in `chat_history.jsonl` modulo trailing /// newlines (`canonical_system_prompt_eq`); a trailing-newline-only difference /// is benign, and this artifact is a convenience mirror, not the source of truth /// (the conversation head is). fn save_system_prompt(session_info: &SessionInfo, system_prompt: &str) { let dir = crate::session::persistence::session_dir(session_info); if let Err(e) = std::fs::create_dir_all(&dir) { tracing::warn!(?e, "failed to create session dir for system_prompt.txt"); return; } let path = dir.join(SYSTEM_PROMPT_FILENAME); if let Err(e) = std::fs::write(&path, system_prompt) { tracing::warn!(?e, "failed to write system_prompt.txt"); } } /// Load the canonical system prompt from `{session_dir}/system_prompt.txt`. /// /// Returns `None` for sessions created before this artifact existed. /// Callers should fall back to extracting from `chat_history.jsonl` if absent. #[expect(dead_code, reason = "API for future viewers/debug tools")] pub(crate) fn load_system_prompt(session_info: &SessionInfo) -> Option { let dir = crate::session::persistence::session_dir(session_info); load_system_prompt_from_dir(&dir) } fn load_system_prompt_from_dir(session_dir: &std::path::Path) -> Option { std::fs::read_to_string(session_dir.join(SYSTEM_PROMPT_FILENAME)).ok() } /// Load the canonical prompt context from `{session_dir}/prompt_context.json`. /// /// Returns `None` for sessions without a persisted context. #[expect(dead_code, reason = "API for future viewers/debug tools")] pub(crate) fn load_prompt_context(session_info: &SessionInfo) -> Option { let dir = crate::session::persistence::session_dir(session_info); load_prompt_context_from_dir(&dir) } fn load_prompt_context_from_dir( session_dir: &std::path::Path, ) -> Option { let json = std::fs::read_to_string(session_dir.join(PROMPT_CONTEXT_FILENAME)).ok()?; serde_json::from_str(&json) .map_err(|e| tracing::warn!(?e, "failed to deserialize prompt_context.json")) .ok() } #[cfg(test)] #[path = "acp_session_tests/client_hooks_tests.rs"] mod client_hooks_tests; /// ToolBridge must route file operations through the injected FileSystem, /// not direct disk I/O. When `.with_fs()` is dropped from the builder, /// tools fall back to LocalFs and ACP client-side enforcement stops working. #[cfg(test)] #[path = "acp_session_tests/fs_injection_regression_tests.rs"] mod fs_injection_regression_tests; #[cfg(test)] #[path = "acp_session_tests/interjection_actor_tests.rs"] mod interjection_actor_tests; #[cfg(test)] #[path = "acp_session_tests/permission_auto_mode_tests.rs"] mod permission_auto_mode_tests; /// Resume re-park of the parked `exit_plan_mode` approval. #[cfg(test)] #[path = "acp_session_tests/plan_approval_resume_tests.rs"] mod plan_approval_resume_tests; /// Plan-mode edit gate: read-only except the plan file, even under allow-all. #[cfg(test)] #[path = "acp_session_tests/plan_mode_edit_gate_tests.rs"] mod plan_mode_edit_gate_tests; /// Mid-turn plan-mode toggle: immediate activation + buffered reminder. #[cfg(test)] #[path = "acp_session_tests/plan_mode_midturn_tests.rs"] mod plan_mode_midturn_tests; /// Tests for [`conversation_has_project_instructions`], the idempotence /// helper that gates the spawn-time AGENTS.md / CLAUDE.md injector. /// /// Coverage: /// - True when a tagged [`SyntheticReason::ProjectInstructions`] user item /// is present (the new post-Task-1 form). /// - True when an untagged legacy user item is present whose first text /// part begins with the wrapper-tag prefix written by older shells. /// - False for an empty conversation, for a conversation with only a real /// user message, when the wrapper prefix appears in a non-first content /// part, and when the wrapper prefix is buried mid-text. #[cfg(test)] #[path = "acp_session_tests/project_instructions_idempotence_tests.rs"] mod project_instructions_idempotence_tests; #[cfg(test)] #[path = "acp_session_tests/prompt_mode_transition_tests.rs"] mod prompt_mode_transition_tests; #[cfg(test)] #[path = "acp_session_tests/prompt_queue_actor_tests.rs"] mod prompt_queue_actor_tests; /// Regression coverage for the per-turn `record_token_usage` path. #[cfg(test)] #[path = "acp_session_tests/record_response_token_usage_tests.rs"] mod record_response_token_usage_tests; #[cfg(test)] #[path = "acp_session_tests/replace_system_prompt_tests.rs"] mod replace_system_prompt_tests; #[cfg(test)] #[path = "acp_session_tests/replay_buffer_send_update_tests.rs"] mod replay_buffer_send_update_tests; #[cfg(test)] #[path = "acp_session_tests/reverse_request_session_id_tests.rs"] mod reverse_request_session_id_tests; #[cfg(test)] #[path = "acp_session_tests/rewind_cross_compaction_tests.rs"] mod rewind_cross_compaction_tests; #[cfg(test)] #[path = "acp_session_tests/rewind_synthetic_turn_tests.rs"] mod rewind_synthetic_turn_tests; #[cfg(test)] #[path = "acp_session_tests/rewrite_zero_turn_prefix_tests.rs"] mod rewrite_zero_turn_prefix_tests; /// Pins the `SubagentFinished` usage-fold attribution gate. #[cfg(test)] #[path = "acp_session_tests/subagent_usage_fold_tests.rs"] mod subagent_usage_fold_tests; #[cfg(test)] #[path = "acp_session_tests/support.rs"] mod support; #[cfg(test)] #[path = "acp_session_tests/turn_completion_emit_tests.rs"] mod turn_completion_emit_tests; #[cfg(test)] #[path = "acp_session_tests/usage_categories_tests.rs"] mod usage_categories_tests; #[cfg(test)] mod tool_meta_stamp_tests { //! Pin the `kigi/tool` stamps on the harness emission paths: the early //! ToolCall registered by `prepare_tool_call` and the permission-request //! ToolCallUpdate (a dropped `stamp_tool_meta` call would regress silently). use super::replay_buffer_send_update_tests::make_replay_send_update_fixture; use super::support::test_agent_with_tools; use super::*; use kigi_tools::registry::types::ToolConfig; use kigi_tools::tool_taxonomy::TOOL_META_KEY; use kigi_workspace::permission::PermissionCommand; use tokio::sync::mpsc; fn read_file_call() -> crate::sampling::types::ToolCallResponse { crate::sampling::types::ToolCallResponse { id: "call-stamp-1".to_string(), kind: "function".to_string(), function: crate::sampling::types::ToolCallFunction { name: "read_file".to_string(), arguments: r#"{"target_file":"/tmp/stamp.txt"}"#.to_string(), }, } } /// The `kigi/tool` object from an event's `_meta`, if present. fn tool_meta(meta: Option<&acp::Meta>) -> Option<&serde_json::Value> { meta.and_then(|m| m.get(TOOL_META_KEY)) } #[tokio::test(flavor = "current_thread")] async fn prepare_tool_call_stamps_early_tool_call_and_refinement() { let local = tokio::task::LocalSet::new(); local .run_until(async { let mut fixture = make_replay_send_update_fixture().await; fixture.actor.agent = std::cell::RefCell::new( test_agent_with_tools(vec![ToolConfig::from_id("Kigi:read_file".to_string())]) .await, ); let prepared = fixture .actor .prepare_tool_call(read_file_call(), &mut Vec::new()) .await .expect("prepare_tool_call should not error"); assert!(prepared.is_ok(), "read_file should prepare cleanly"); let mut early = None; let mut refined = None; while let Ok(event) = fixture.event_rx.try_recv() { let SessionEvent::Notification(SessionNotification::Acp(n)) = event else { continue; }; match &n.update { acp::SessionUpdate::ToolCall(tc) => early = Some(tc.meta.clone()), acp::SessionUpdate::ToolCallUpdate(tu) => { refined = Some(tu.meta.clone()); } _ => {} } } let early = early.expect("early ToolCall emitted"); let t = tool_meta(early.as_ref()).expect("early ToolCall carries kigi/tool"); assert_eq!(t["name"], "read_file"); assert_eq!(t["kind"], "read"); assert_eq!(t["namespace"], "kigi"); assert!(t.get("input").is_none(), "identity-only before parse"); let refined = refined.expect("refinement ToolCallUpdate emitted"); let t = tool_meta(refined.as_ref()).expect("refinement carries kigi/tool"); assert_eq!(t["input"]["path"], "/tmp/stamp.txt"); }) .await; } #[tokio::test(flavor = "current_thread")] async fn permission_request_update_carries_tool_meta() { let local = tokio::task::LocalSet::new(); local .run_until(async { let mut fixture = make_replay_send_update_fixture().await; fixture.actor.agent = std::cell::RefCell::new( test_agent_with_tools(vec![ToolConfig::from_id("Kigi:read_file".to_string())]) .await, ); let (perm_tx, mut perm_rx) = mpsc::unbounded_channel(); fixture.actor.permissions = PermissionHandle::Actor { cmd_tx: perm_tx, yolo_state: Arc::new(std::sync::atomic::AtomicBool::new(false)), auto_state: Arc::new(std::sync::atomic::AtomicBool::new(false)), side_query_wired: Arc::new(std::sync::atomic::AtomicBool::new(false)), yolo_pin: None, deny_read_globs: Arc::new(vec![]), in_flight: Arc::new(std::sync::atomic::AtomicUsize::new(0)), }; let captured: Arc>> = Arc::new(tokio::sync::Mutex::new(None)); let captured_in_task = captured.clone(); tokio::task::spawn_local(async move { while let Some(cmd) = perm_rx.recv().await { if let PermissionCommand::Request { tool_call_update, respond_to, .. } = cmd { *captured_in_task.lock().await = Some(tool_call_update); let _ = respond_to.send(Decision::Allow); } } }); let prepared = fixture .actor .prepare_tool_call(read_file_call(), &mut Vec::new()) .await .expect("prepare_tool_call should not error"); assert!(prepared.is_ok(), "allowed read_file should prepare cleanly"); let update = captured .lock() .await .take() .expect("permission request must have been issued"); let t = tool_meta(update.meta.as_ref()) .expect("permission-request ToolCallUpdate carries kigi/tool"); assert_eq!(t["name"], "read_file"); assert_eq!(t["kind"], "read"); assert_eq!(t["input"]["path"], "/tmp/stamp.txt"); }) .await; } } /// Drop guard that records aggregate turn metrics on the current tracing span struct TurnMetrics { turn_tool_count: u64, turn_model_calls: u64, span: tracing::Span, } impl TurnMetrics { fn new() -> Self { Self { turn_tool_count: 0, turn_model_calls: 0, span: tracing::Span::current(), } } fn record_model_response(&mut self, num_tool_calls: usize) { self.turn_model_calls += 1; self.turn_tool_count += num_tool_calls as u64; } } impl Drop for TurnMetrics { fn drop(&mut self) { self.span.record("turn_tool_count", self.turn_tool_count); self.span.record("turn_model_calls", self.turn_model_calls); } } /// Token rotation on the sampler/inference path is owned by the /// proactive refresh loop and the per-turn pre-request refresh /// (`refresh_token_if_expired`). `handle_sampling_failure` surfaces /// auth errors to the caller and never invokes the refresher itself. #[cfg(test)] #[path = "acp_session_tests/auth_error_no_retry_tests.rs"] mod auth_error_no_retry_tests; /// Regression coverage for the auto-wake suppression sweep + shutdown /// drain. These exercise the helpers added to fix the trailing /// `` chat history bug. #[cfg(test)] #[path = "acp_session_tests/auto_wake_suppression_tests.rs"] mod auto_wake_suppression_tests; #[cfg(test)] #[path = "acp_session_tests/between_turn_completion_tests.rs"] mod between_turn_completion_tests; #[cfg(test)] #[path = "acp_session_tests/build_tool_parse_error_message_tests.rs"] mod build_tool_parse_error_message_tests; #[cfg(test)] #[path = "acp_session_tests/cancel_running_task_tests.rs"] mod cancel_running_task_tests; #[cfg(test)] #[path = "acp_session_tests/feedback_turn_lookup_tests.rs"] mod feedback_turn_lookup_tests; #[cfg(test)] #[path = "acp_session_tests/goal/goal_backoff_tests.rs"] mod goal_backoff_tests; #[cfg(test)] #[path = "acp_session_tests/goal/goal_classifier_e2e_tests.rs"] mod goal_classifier_e2e_tests; #[cfg(test)] #[path = "acp_session_tests/goal/goal_planner_e2e_tests.rs"] mod goal_planner_e2e_tests; #[cfg(test)] #[path = "acp_session_tests/goal/goal_reminder_subagent_rules_tests.rs"] mod goal_reminder_subagent_rules_tests; #[cfg(test)] #[path = "acp_session_tests/goal/goal_strategist_e2e_tests.rs"] mod goal_strategist_e2e_tests; #[cfg(test)] #[path = "acp_session_tests/goal/goal_summarizer_e2e_tests.rs"] mod goal_summarizer_e2e_tests; #[cfg(test)] #[path = "acp_session_tests/graph/graph_e2e_tests.rs"] mod graph_e2e_tests; #[cfg(test)] #[path = "acp_session_tests/idle_resume_tests.rs"] mod idle_resume_tests; #[cfg(test)] #[path = "acp_session_tests/inline_auto_compact_flow_tests.rs"] mod inline_auto_compact_flow_tests; #[cfg(test)] #[path = "acp_session_tests/interjection_tests.rs"] mod interjection_tests; #[cfg(test)] #[path = "acp_session_tests/laziness/laziness_debug_tests.rs"] mod laziness_debug_tests; #[cfg(test)] #[path = "acp_session_tests/laziness/laziness_detector_tests.rs"] mod laziness_detector_tests; #[cfg(test)] #[path = "acp_session_tests/laziness/laziness_integration_tests.rs"] mod laziness_integration_tests; #[cfg(test)] #[path = "acp_session_tests/load_user_prompts_tests.rs"] mod load_user_prompts_tests; #[cfg(test)] #[path = "acp_session_tests/memory_config_tests.rs"] mod memory_config_tests; #[cfg(test)] #[path = "acp_session_tests/parallel_dispatch_tests.rs"] mod parallel_dispatch_tests; #[cfg(test)] #[path = "acp_session_tests/prompt_context_persistence_tests.rs"] mod prompt_context_persistence_tests; #[cfg(test)] #[path = "acp_session_tests/recap_display_only_tests.rs"] mod recap_display_only_tests; #[cfg(test)] #[path = "acp_session_tests/reminder_policy_tests.rs"] mod reminder_policy_tests; #[cfg(test)] #[path = "acp_session_tests/session_thread_tests.rs"] mod session_thread_tests; #[cfg(test)] #[path = "acp_session_tests/tool_auth_retry_tests.rs"] mod tool_auth_retry_tests; #[cfg(test)] #[path = "acp_session_tests/turn/turn_end_guard_tests.rs"] mod turn_end_guard_tests; #[cfg(test)] #[path = "acp_session_tests/wait_for_mcp_prefix_tests.rs"] mod wait_for_mcp_prefix_tests; #[cfg(test)] #[path = "acp_session_tests/web_search_e2e_tests.rs"] mod web_search_e2e_tests;