Files
Kigi-CLI/crates/codegen/kigi-shell/src/session/acp_session_impl/turn.rs
T
ZacharyZhang-NY d6c20fc13f M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
2026-07-17 05:31:01 -04:00

2203 lines
96 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Turn-execution concern for `SessionActor` (`handle_prompt`, turn-end,
//! sampling loop).
use super::*;
/// Synthetic tool the model calls to return its schema-constrained final answer
/// on backends that can't constrain output natively (Messages API). Intercepted
/// in the loop, never executed as a real tool.
const STRUCTURED_OUTPUT_TOOL: &str = "StructuredOutput";
/// Max times the model may re-call `StructuredOutput` with non-conforming args
/// before the turn ends with the last validation error.
const STRUCTURED_OUTPUT_MAX_RETRIES: u32 = 3;
/// What a `StructuredOutput` tool call means for the turn (see
/// `handle_structured_output_tool_call`).
enum StructuredOutputStep {
/// Accepted, or retries exhausted: the carried result is the final output.
Complete(Result<serde_json::Value, String>),
/// Non-conforming args; a corrective tool_result was pushed — re-sample.
Retry,
/// No sole StructuredOutput call (absent, or co-emitted with real tools that
/// should run this round).
Proceed,
}
/// Parse `raw` as JSON and validate it against a `validator` compiled once per
/// turn. Returns the value on success, or a human-readable error (surfaced to
/// the model on retry and to the client as `structuredOutputError`). A `validator`
/// of `Err` means the user's schema itself was invalid.
fn validate_structured_output(
validator: &Result<jsonschema::Validator, String>,
raw: &str,
) -> Result<serde_json::Value, String> {
let validator = validator.as_ref().map_err(Clone::clone)?;
let value: serde_json::Value = serde_json::from_str(raw.trim())
.map_err(|e| format!("model output was not valid JSON: {e}"))?;
match validator.validate(&value) {
Ok(()) => Ok(value),
Err(e) => Err(format!("output does not match the required schema: {e}")),
}
}
/// Result of the turn-end usage drain (and cancel's no-drain snapshot).
///
/// **Ledger marks** only when [`Self::fail_closed`]. Sticky and background
/// live are **report-level only** (tokens still land on the session ledger).
pub(super) struct UsageDrainOutcome {
/// Query failure, FG still live after timeout/cancel. Marks both
/// the prompt and session bills incomplete. (True apply-miss stains
/// ledgers at fold time via `mark_apply_miss_incomplete`, not here.)
pub(super) fail_closed: bool,
/// A background child is still running: only this prompt's report is
/// incomplete; its spend reaches the session ledger at completion.
pub(super) background_live: bool,
/// Pin-scoped sticky (session-only attribution or apply-miss report).
/// Report incomplete only — does not stain ledgers by itself.
pub(super) sticky_report: bool,
}
impl UsageDrainOutcome {
/// Wire / attach incomplete: fail-closed background sticky.
pub(super) fn report_incomplete(&self) -> bool {
self.fail_closed || self.background_live || self.sticky_report
}
/// Map an outstanding reply without a multi-second drain (cancel path).
/// Same policy as freeze's terminal outcome: FG live → fail-closed;
/// sticky and background → report only.
pub(super) fn from_outstanding_reply(
reply: Option<
&kigi_tools::implementations::grok_build::task::types::SubagentOutstandingReply,
>,
) -> Self {
match reply {
None => Self {
fail_closed: true,
background_live: false,
sticky_report: false,
},
Some(r) => Self {
fail_closed: !r.live_ids.is_empty(),
background_live: r.background_live,
sticky_report: r.subagent_usage_not_applied,
},
}
}
}
/// Accumulates a turn's per-call token usage and tool-call presence across the
/// agentic loop's model calls, recording running totals on the turn span. Kept
/// out of the loop body so telemetry bookkeeping doesn't obscure control flow.
#[derive(Default)]
struct TurnSpanTotals {
input_tokens: i64,
output_tokens: i64,
cache_read_tokens: i64,
has_tool_call: bool,
}
impl TurnSpanTotals {
/// Fold one model response into the totals (tokens sum — each call is billed
/// its full prompt; has_tool_call OR-s — the final call has none) and update
/// the span. `stop_reason` is last-wins (the terminal reason), not summed.
fn record(&mut self, span: &tracing::Span, response: &ConversationResponse) {
if let Some(u) = response.usage.as_ref() {
self.input_tokens += i64::from(u.prompt_tokens);
self.output_tokens += i64::from(u.completion_tokens);
self.cache_read_tokens += i64::from(u.cached_prompt_tokens);
span.record("input_tokens", self.input_tokens);
span.record("output_tokens", self.output_tokens);
span.record("cache_read_tokens", self.cache_read_tokens);
}
if let Some(sr) = response.stop_reason {
span.record("stop_reason", sr.as_str());
}
self.has_tool_call |= !response.tool_calls().is_empty();
span.record("response.has_tool_call", self.has_tool_call);
}
}
/// How the turn's per-block user-message echo is published to clients /
/// `updates.jsonl`.
///
/// Every turn consumes a `prompt_index`, and rewind / fork truncation
/// (`replay_to_prompt`, `updates_truncate_for_prompt`) recover turn
/// boundaries by counting persisted `UserMessageChunk` runs — so every mode
/// persists the echo. Turns whose content must not render as a user prompt
/// (notification drain) are hidden by the *pager* via the
/// `hideFromScrollback` chunk meta, not by omitting the persisted line.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum UserEchoMode {
/// Live + persist (real user / cron / skill turns).
Broadcast,
/// Persist without live broadcast. Interject-fallback: panes already
/// rendered the text, so a live echo would duplicate it. Notification
/// drain: model-only content (the UI surfaces it via side channels:
/// monitor gutter, task pane) that no pane should render live.
PersistOnly,
}
fn user_echo_mode(prompt_id: &str) -> UserEchoMode {
if prompt_id.starts_with(super::interjection::INTERJECT_FALLBACK_PROMPT_PREFIX) {
return UserEchoMode::PersistOnly;
}
match super::super::PromptOrigin::from_prompt_id(prompt_id) {
super::super::PromptOrigin::NotificationDrain => UserEchoMode::PersistOnly,
_ => UserEchoMode::Broadcast,
}
}
impl SessionActor {
/// Run the image-normalization pipeline (re-encode caps, min-side and
/// integrity checks) and surface its outcomes: compression / re-encode
/// fallback / dropped notices are appended to `text_out` (TEXT only —
/// image data never enters a string) and mirrored as
/// `ImageCompressed`/`ImageDropped` notifications. Returns the surviving
/// images. Single owner of the notice/notify wiring, shared by the
/// prompt path and the interjection drain.
pub(crate) async fn normalize_images_with_notices(
&self,
text_out: &mut String,
images: Vec<acp::ImageContent>,
is_cursor: bool,
) -> Vec<acp::ImageContent> {
let mut norm_result =
crate::session::image_normalize::normalize_images(images, is_cursor).await;
let user_images = std::mem::take(&mut norm_result.images);
use crate::extensions::notification::ImageCompressedEntry;
if !norm_result.compressed.is_empty() {
text_out.push_str(&crate::session::image_normalize::render_compression_notice(
&norm_result.compressed,
is_cursor,
));
let message = norm_result
.compressed
.iter()
.map(|c| c.display())
.collect::<Vec<_>>()
.join("; ");
let images = norm_result
.compressed
.iter()
.map(ImageCompressedEntry::from)
.collect();
self.send_xai_notification(XaiSessionUpdate::ImageCompressed { images, message })
.await;
}
if !norm_result.re_encode_fallbacks.is_empty() {
text_out.push_str(
&crate::session::image_normalize::render_re_encode_fallback_notice(
&norm_result.re_encode_fallbacks,
is_cursor,
),
);
self.send_xai_notification(XaiSessionUpdate::ImageCompressed {
images: vec![],
message: norm_result.re_encode_fallbacks.join(" "),
})
.await;
}
if let Some((notice, notes)) = crate::session::image_normalize::dropped_to_envelope(
std::mem::take(&mut norm_result.dropped),
is_cursor,
) {
text_out.push_str(&notice);
self.send_xai_notification(XaiSessionUpdate::ImageDropped { notes })
.await;
}
user_images
}
#[tracing::instrument(
name = "session.handle_prompt",
skip_all,
fields(
session_id = %self.session_info.id.0,
prompt_id = %prompt_id,
prompt_length = tracing::field::Empty,
command_name = tracing::field::Empty,
command_source = tracing::field::Empty,
)
)]
pub(super) async fn handle_prompt(
self: &Arc<Self>,
prompt_id: &str,
prompt_blocks: Vec<acp::ContentBlock>,
prompt_mode: PromptMode,
prompt_client_identifier: Option<String>,
prompt_screen_mode: Option<String>,
verbatim: bool,
json_schema: Option<serde_json::Value>,
persist_ack: Option<oneshot::Sender<()>>,
) -> PromptTurnResult {
let handle_prompt_start = std::time::Instant::now();
let prompt_length: usize = prompt_blocks
.iter()
.map(|b| match b {
acp::ContentBlock::Text(t) => t.text.len(),
_ => 0,
})
.sum();
tracing::Span::current().record("prompt_length", prompt_length as i64);
*self.active_skill.lock() = None;
kigi_log::unified_log::info(
"shell.handle_prompt.start",
Some(self.session_info.id.0.as_ref()),
Some(serde_json::json!(
{ "prompt_id" : prompt_id, "block_count" : prompt_blocks.len(), }
)),
);
if !super::super::PromptOrigin::from_prompt_id(prompt_id).is_synthetic() {
self.cancel_pending_recap_for_new_prompt();
}
*self.turn_start_prompt_mode.lock() = prompt_mode;
*self.turn_prompt_mode.lock() = prompt_mode;
self.signals_handle().increment_turn();
self.reconcile_plan_mode_with_prompt(prompt_mode);
let _turn_active_guard =
TurnActiveGuard::activate(self.tool_context.is_turn_active.as_ref());
let _session_turn_active_guard = TurnActiveGuard::activate(Some(&self.session_turn_active));
let turn_start_input = kigi_agent_lifecycle::TurnStartInput::new(
super::super::PromptOrigin::from_prompt_id(prompt_id).is_synthetic(),
);
for contributor in self.extension_registry.turn_lifecycle_contributors() {
contributor.on_turn_start(&turn_start_input).await;
}
if let Ok(mut pending) = self.rewind_pending_prompt.lock()
&& let Some(prev_text) = pending.take()
{
let new_text = prompt_blocks.iter().fold(String::new(), |mut acc, b| {
if let acp::ContentBlock::Text(t) = b {
acc.push_str(&t.text);
}
acc
});
if new_text.trim() == prev_text.trim() {
self.signals_handle().record_regeneration();
} else {
self.signals_handle().record_edit_and_retry();
}
}
if let Some(bash_command) = Self::extract_bash_command(&prompt_blocks) {
return self
.handle_direct_bash_command(prompt_id, bash_command, &prompt_blocks)
.await;
}
let slash_skills = self
.agent
.borrow()
.tool_bridge()
.clone()
.slash_skills()
.await;
let skill_rewrite = if crate::session::is_cursor_user_template(
&self.agent.borrow().definition().user_message_template,
) {
slash_commands::SkillSlashRewrite::Passthrough
} else {
slash_commands::SkillSlashRewrite::RewriteToRun
};
let availability = self.command_availability().await;
let mut pending_skill_information: Option<String> = None;
let prompt_blocks = match slash_commands::resolve(
prompt_blocks,
&slash_skills,
availability,
skill_rewrite,
) {
Ok(blocks) => blocks,
Err(SlashCommandOutcome::Builtin(action)) => {
let text_block =
|text: String| acp::ContentBlock::Text(acp::TextContent::new(text));
{
let span = tracing::Span::current();
span.record("command_name", action.command_name());
span.record("command_source", "builtin");
}
match action {
BuiltinAction::GoalSet {
objective,
token_budget,
} => {
let reminder = self.setup_goal(&objective, token_budget).await;
vec![text_block(reminder), text_block(objective)]
}
BuiltinAction::GoalResume => match self.resume_goal().await {
GoalResumeOutcome::Inference { reminder, user_msg } => {
self.send_slash_command_output(&user_msg).await;
vec![text_block(reminder)]
}
GoalResumeOutcome::Message(msg) => {
self.send_slash_command_output(&msg).await;
return ok_end_turn(0, None);
}
},
_ => return self.execute_builtin_slash_command(action).await,
}
}
Err(SlashCommandOutcome::InvokeSkill {
blocks: original_blocks,
skills: parsed_skills,
}) => {
if let Some(first) = parsed_skills.first() {
*self.active_skill.lock() = Some(first.name.clone());
let span = tracing::Span::current();
span.record("command_name", first.name.as_str());
span.record(
"command_source",
if first.plugin_name.is_some() {
"plugin"
} else {
"skill"
},
);
}
for sk in &parsed_skills {
let skill_source = if sk.plugin_name.is_some() {
"plugin"
} else {
crate::session::telemetry::skill_source_label(
&sk.skill_path,
self.session_info.cwd.as_str(),
)
};
tracing::info_span!(
"skill.activated", skill_name = % sk.name, invocation_trigger =
"slash_command", skill_source = skill_source,
)
.in_scope(|| {});
if let Some(ref pname) = sk.plugin_name {
tracing::info_span!(
"plugin.used", plugin_name = % pname, skill_name = % sk.name,
)
.in_scope(|| {});
}
}
pending_skill_information = slash_commands::build_skill_information_for_refs(
&parsed_skills,
&slash_skills,
&self.session_id_string(),
)
.await;
original_blocks
}
};
self.events.begin_turn();
let model_id = self.current_model_id().await;
let turn_number = self.chat_state_handle.get_prompt_index().await as u64;
self.current_turn_number.set(turn_number);
let yolo_mode = self.permissions.is_yolo_mode();
let msg_count = self.chat_state_handle.get_conversation_len().await;
let redirect_kind = if matches!(
super::super::PromptOrigin::from_prompt_id(prompt_id),
super::super::PromptOrigin::User
) {
self.events.take_prior_redirect_kind()
} else {
None
};
self.emit_event(crate::session::events::Event::TurnStarted {
session_id: self.session_id_string(),
turn_number,
model_id: model_id.clone(),
yolo_mode,
conversation_message_count: msg_count,
session_relationship: crate::session::events::SessionRelationship::Primary,
schema_version: crate::session::events::EVENT_SCHEMA_VERSION.into(),
redirect_kind,
});
self.observability_bridge
.emit(
kigi_tool_protocol::session_event::SessionEvent::TurnStarted {
turn_number,
model_id: model_id.clone(),
yolo_mode,
},
)
.await;
self.send_before_turn_event(kigi_tool_protocol::turn_hook::BeforeTurnPayload {
turn_number: self.chat_state_handle.get_prompt_index().await as u64,
model_id: model_id.clone(),
yolo_mode: self.permissions.is_yolo_mode(),
conversation_message_count: msg_count,
session_relationship: kigi_tool_protocol::turn_hook::DEFAULT_SESSION_RELATIONSHIP
.to_string(),
schema_version: crate::session::events::EVENT_SCHEMA_VERSION.to_string(),
})
.await;
let turn_idx = self.chat_state_handle.get_prompt_index().await as u64;
let current_prompt_index = self.chat_state_handle.get_prompt_index().await;
let origin = super::super::PromptOrigin::from_prompt_id(prompt_id);
let mut chunk_meta = serde_json::Map::new();
chunk_meta.insert("modelId".into(), serde_json::json!(model_id));
chunk_meta.insert(
"promptIndex".into(),
serde_json::json!(current_prompt_index),
);
if origin.hide_user_echo_from_scrollback() {
chunk_meta.insert("hideFromScrollback".into(), serde_json::json!(true));
}
let user_chunk_meta = Some(chunk_meta);
self.chat_state_handle.increment_prompt_index();
let text = prompt_blocks.iter().fold(String::new(), |mut acc, b| {
if let acp::ContentBlock::Text(t) = b {
acc.push_str(&t.text);
}
acc
});
let trimmed = text.trim().to_string();
if !trimmed.is_empty() {
self.chat_state_handle.cache_prompt_text(trimmed);
}
*self.tool_context.prompt_index.lock().await = current_prompt_index;
self.file_state_tracker
.begin_prompt(current_prompt_index)
.await;
let echo_mode = user_echo_mode(prompt_id);
for block in prompt_blocks.iter() {
let update = acp::SessionUpdate::UserMessageChunk(
acp::ContentChunk::new(block.clone()).meta(user_chunk_meta.clone()),
);
let notification_meta = self.build_notification_meta();
let notification = acp::SessionNotification::new(self.session_info.id.clone(), update)
.meta(notification_meta.as_object().cloned());
if echo_mode == UserEchoMode::PersistOnly {
let _ = self
.notifications
.persistence_tx
.send(PersistenceMsg::Update(
crate::session::storage::SessionUpdate::Acp(Box::new(notification)),
));
} else {
self.emit_notification_direct(notification).await;
}
}
let crate::session::prompt_parser::ParsedPrompt {
mut context,
query,
skill_information: skill_info,
images: mut raw_images,
is_cursor,
} = match parse_prompt_with_skills(
&prompt_blocks,
self.tool_context.cwd.to_path_buf(),
&self.session_info,
verbatim,
self.is_cursor_harness(),
pending_skill_information.take().unwrap_or_default(),
)
.await
{
Ok(v) => v,
Err(err) => {
tracing::warn!("Invalid prompt: {}", err.message);
return Err(err);
}
};
let recovered = crate::session::placeholder_images::recover_orphan_placeholders(
&query,
&mut raw_images,
std::path::Path::new(&self.session_info.cwd),
);
if recovered > 0 {
tracing::info!(
session_id = % self.session_info.id, recovered,
"server-side placeholder fallback: loaded orphan image(s) from disk",
);
}
let query = crate::session::placeholder_images::strip_paths_from_image_placeholders(query);
let user_images = self
.normalize_images_with_notices(&mut context, raw_images, is_cursor)
.await;
let (query, extra_images) = if !self.is_cursor_harness() {
let extraction = kigi_tools::util::base64_images::extract_base64_images(query);
if extraction.images.is_empty() {
(extraction.text, Vec::new())
} else {
let cleaned_text = extraction.text;
let count = extraction.images.len();
tracing::info!(
session_id = % self.session_info.id, count,
"base64 images extracted from user query",
);
let acp_imgs: Vec<agent_client_protocol::ImageContent> = extraction
.images
.into_iter()
.map(|img| agent_client_protocol::ImageContent::new(img.data, img.mime_type))
.collect();
let nr = crate::session::image_normalize::normalize_images(acp_imgs, false).await;
if !nr.re_encode_fallbacks.is_empty() {
tracing::warn!(
session_id = % self.session_info.id, notes = % nr
.re_encode_fallbacks.join(" "),
"Extracted user query image kept original after re-encode failure",
);
}
(cleaned_text, nr.images)
}
} else {
(query, Vec::new())
};
let assembled = crate::session::prompt_parser::ParsedPrompt::assemble_parts_with_skills(
&context,
&query,
&skill_info,
is_cursor,
);
let (user_message, _truncated_local_path) = if verbatim {
(assembled, None)
} else {
self.maybe_truncate_large_prompt_with_skills(
context,
query,
skill_info,
is_cursor,
current_prompt_index,
)
.await
};
let _ = self
.notifications
.persistence_tx
.send(PersistenceMsg::ContentChunk(PersistenceContentChunk::new(
prompt_blocks.to_vec(),
)));
let model_id = self
.chat_state_handle
.get_sampling_config()
.await
.map(|c| c.model)
.unwrap_or_default();
self.maybe_inject_mcp_reminder().await;
self.maybe_inject_mcp_connecting_reminder().await;
self.maybe_inject_date_rollover_reminder().await;
self.inject_plan_mode_reminders().await;
self.inject_resumed_tasks_reminder();
self.drain_between_turn_completions().await;
let user_message = if user_images.is_empty() {
user_message
} else if self.is_cursor_harness() {
self.transcribe_user_images(user_message, &user_images)
.await?
} else {
let session_dir =
crate::session::persistence::session_dir(&crate::session::info::Info {
id: self.session_info.id.clone(),
cwd: self.session_info.cwd.clone(),
});
crate::session::image_describe::persist_and_prepend_image_files(
&session_dir,
&user_images,
&user_message,
)
.map_err(|e| {
acp::Error::internal_error()
.data(format!("failed to save user images to assets dir: {e}"))
})?
};
let attached_image_refs = if self.is_cursor_harness() {
Vec::new()
} else {
crate::session::placeholder_images::attached_image_references(&user_images)
};
self.tool_bridge_handle()
.update_resource(kigi_tools::types::resources::AttachedImages(
attached_image_refs,
))
.await;
let prompt_text_for_hook = user_message.clone();
{
let origin = super::super::PromptOrigin::from_prompt_id(prompt_id);
if matches!(origin, super::super::PromptOrigin::User) {
self.maybe_inject_interrupt_reminder().await;
}
let mut user_chat = match &origin {
super::super::PromptOrigin::TaskCompleted { .. } => {
ConversationItem::task_completed(user_message)
}
super::super::PromptOrigin::SubagentCompleted { .. } => {
ConversationItem::subagent_completed(user_message)
}
super::super::PromptOrigin::NotificationDrain => {
ConversationItem::notification_drain(user_message)
}
super::super::PromptOrigin::GoalSummary => {
ConversationItem::goal_summary(user_message)
}
super::super::PromptOrigin::GoalClassifierNudge => {
ConversationItem::goal_classifier_nudge(user_message)
}
super::super::PromptOrigin::SchedulerFired => {
ConversationItem::scheduler_fired(user_message)
}
super::super::PromptOrigin::PlanResume => ConversationItem::user(user_message),
super::super::PromptOrigin::User => {
let mut item = ConversationItem::user(user_message);
if let Some(interrupt) = self
.events
.take_prior_interrupt_category()
.and_then(crate::session::events::prior_turn_interrupt_from_cancellation)
{
item.set_prior_turn_interrupt(interrupt);
}
item
}
};
user_chat.set_prompt_index(current_prompt_index);
if !self.is_cursor_harness() {
for image in &user_images {
user_chat.add_image(pick_user_image_url(image));
}
for image in &extra_images {
user_chat.add_image(format!("data:{};base64,{}", image.mime_type, image.data));
}
}
if let Some(ack) = persist_ack {
if self
.chat_state_handle
.push_user_message_and_ack(user_chat)
.await
.is_some()
{
let (flush_tx, flush_rx) = oneshot::channel();
if self
.notifications
.persistence_tx
.send(PersistenceMsg::FlushAndAck {
respond_to: flush_tx,
})
.is_ok()
&& flush_rx.await.is_ok()
{
let _ = ack.send(());
} else {
tracing::error!(
session_id = % self.session_info.id.0, prompt_id = %
prompt_id, "persist_ack flush barrier failed"
);
}
} else {
tracing::error!(
session_id = % self.session_info.id.0, prompt_id = % prompt_id,
"persist_ack skipped: chat-state actor unavailable"
);
}
} else {
self.chat_state_handle.push_user_message(user_chat);
}
}
self.dispatch_hook(
kigi_hooks::event::HookEventName::UserPromptSubmit,
kigi_hooks::event::HookPayload::UserPromptSubmit {
prompt: Some(prompt_text_for_hook),
},
Some(prompt_id),
None,
)
.await;
let turn_scope_guard =
TurnSubagentScopeGuard::new(self.current_prompt_id.clone(), prompt_id.to_string());
let turn_model_id = self.current_model_id().await;
let doom_event_model = turn_model_id.clone();
let turn_timer = std::time::Instant::now();
let result = {
loop {
if self.goal_harness_enabled() {
let goal_loop_active = self.goal_tracker.lock().status()
== Some(crate::session::goal_tracker::GoalStatus::Active);
self.set_goal_loop_active_resource(goal_loop_active).await;
}
let round = self
.process_conversation_turn_with_recovery(prompt_id, json_schema.clone())
.await;
if !matches!(round, Ok(TurnOutcome::Completed { .. })) {
break round;
}
if matches!(round, Ok(TurnOutcome::Completed { refusal: true, .. })) {
break round;
}
let goal_active = laziness_injection_active(
self.goal_harness_enabled(),
self.goal_tracker.lock().status(),
);
if !goal_active {
break round;
}
match self.run_goal_round_end().await {
GoalRoundDecision::Continue(directive) => {
self.inject_goal_continuation_message(directive).await;
}
GoalRoundDecision::EndTurn => break round,
}
}
};
let turn_duration_ms = turn_timer.elapsed().as_millis() as u64;
let handle_prompt_elapsed_ms = handle_prompt_start.elapsed().as_millis() as u64;
kigi_log::unified_log::info(
"shell.handle_prompt.done",
Some(self.session_info.id.0.as_ref()),
Some(serde_json::json!(
{ "prompt_id" : prompt_id, "total_elapsed_ms" :
handle_prompt_elapsed_ms, "turn_elapsed_ms" : turn_duration_ms,
"pre_turn_ms" : handle_prompt_elapsed_ms
.saturating_sub(turn_duration_ms), "ok" : result.is_ok(), }
)),
);
let turn_tool_count = self.events.tool_count_this_turn();
let bridge_outcome = turn_result_to_hook_outcome(&result);
self.observability_bridge
.emit(kigi_tool_protocol::session_event::SessionEvent::TurnEnded {
turn_number: current_prompt_index as u64,
outcome: bridge_outcome,
duration_ms: turn_duration_ms,
tool_call_count: turn_tool_count,
model_id: turn_model_id.clone(),
})
.await;
match &result {
Ok(TurnOutcome::Completed { .. }) => {
self.emit_turn_ended(
crate::session::events::TurnOutcomeLabel::Completed,
None,
None,
);
self.send_after_turn_event(kigi_tool_protocol::turn_hook::AfterTurnPayload {
turn_number: current_prompt_index as u64,
outcome: kigi_tool_protocol::turn_hook::TurnHookOutcome::Completed,
duration_ms: turn_duration_ms,
tool_call_count: turn_tool_count,
model_id: turn_model_id.clone(),
written_repo_paths: Vec::new(),
cancellation_category: None,
cancellation_context: None,
})
.await;
}
Ok(TurnOutcome::Cancelled { category, context }) => {
self.emit_turn_ended(
crate::session::events::TurnOutcomeLabel::Cancelled,
*category,
context.clone(),
);
if let Some(cause) = category {
self.events.set_prior_interrupt_category(*cause);
}
self.send_after_turn_event(kigi_tool_protocol::turn_hook::AfterTurnPayload {
turn_number: current_prompt_index as u64,
outcome: kigi_tool_protocol::turn_hook::TurnHookOutcome::Cancelled,
duration_ms: turn_duration_ms,
tool_call_count: turn_tool_count,
model_id: turn_model_id.clone(),
written_repo_paths: Vec::new(),
cancellation_category: cancellation_category_wire_string(*category),
cancellation_context: context.clone(),
})
.await;
}
Ok(TurnOutcome::MaxTurnsReached { limit }) => {
tracing::info!(limit, "turn ended: max_turns reached");
self.emit_turn_ended(
crate::session::events::TurnOutcomeLabel::Cancelled,
None,
Some(serde_json::json!(
{ "reason" : "max_turns_reached", "limit" : limit, }
)),
);
self.send_after_turn_event(kigi_tool_protocol::turn_hook::AfterTurnPayload {
turn_number: current_prompt_index as u64,
outcome: kigi_tool_protocol::turn_hook::TurnHookOutcome::Cancelled,
duration_ms: turn_duration_ms,
tool_call_count: turn_tool_count,
model_id: turn_model_id.clone(),
written_repo_paths: Vec::new(),
cancellation_category: None,
cancellation_context: Some(serde_json::json!(
{ "reason" : "max_turns_reached", "limit" : limit, }
)),
})
.await;
}
Err(err) => {
self.emit_turn_ended(crate::session::events::TurnOutcomeLabel::Error, None, None);
self.send_after_turn_event(kigi_tool_protocol::turn_hook::AfterTurnPayload {
turn_number: current_prompt_index as u64,
outcome: kigi_tool_protocol::turn_hook::TurnHookOutcome::Error,
duration_ms: turn_duration_ms,
tool_call_count: turn_tool_count,
model_id: turn_model_id.clone(),
written_repo_paths: Vec::new(),
cancellation_category: None,
cancellation_context: None,
})
.await;
let error_category = Self::classify_turn_error(err);
self.dispatch_hook(
kigi_hooks::event::HookEventName::StopFailure,
kigi_hooks::event::HookPayload::StopFailure {
error: format!("{err}"),
},
Some(prompt_id),
None,
)
.await;
}
}
let doom_tally = std::mem::take(&mut *self.doom_loop_turn_tally.lock());
doom_tally.fired();
let stop_reason_str = match &result {
Ok(TurnOutcome::Completed { .. }) => "end_turn",
Ok(TurnOutcome::Cancelled { .. }) | Ok(TurnOutcome::MaxTurnsReached { .. }) => {
"cancelled"
}
Err(_) => "error",
};
self.dispatch_hook(
kigi_hooks::event::HookEventName::Stop,
kigi_hooks::event::HookPayload::Stop {
reason: stop_reason_str.to_string(),
},
Some(prompt_id),
None,
)
.await;
match &result {
Ok(TurnOutcome::Completed { .. }) => {
for contributor in self.extension_registry.turn_lifecycle_contributors() {
contributor
.on_turn_done(&kigi_agent_lifecycle::TurnDoneInput)
.await;
}
}
Ok(TurnOutcome::Cancelled { .. }) | Ok(TurnOutcome::MaxTurnsReached { .. }) => {
let input = kigi_agent_lifecycle::TurnAbortInput::new(
kigi_agent_lifecycle::TurnAbortReason::Interrupted,
);
for contributor in self.extension_registry.turn_lifecycle_contributors() {
contributor.on_turn_abort(&input).await;
}
}
Err(err) => {
let message = err.to_string();
let input = kigi_agent_lifecycle::TurnErrorInput { message: &message };
for contributor in self.extension_registry.turn_lifecycle_contributors() {
contributor.on_turn_error(&input).await;
}
}
}
if matches!(
result,
Ok(TurnOutcome::Cancelled { .. }) | Ok(TurnOutcome::MaxTurnsReached { .. })
) {
self.cancel_running_turn_subagents();
}
self.flush_to_disk().await;
self.file_state_tracker
.end_prompt(&self.tool_context.fs, current_prompt_index)
.await;
if let Some(mut rewind_point) = self
.file_state_tracker
.get_rewind_point(current_prompt_index)
.await
{
rewind_point.normalize_to_relative(self.tool_context.cwd.as_ref());
let _ = self
.notifications
.persistence_tx
.send(PersistenceMsg::RewindPoint(rewind_point));
}
match result {
Ok(outcome) => {
let usage = self.freeze_prompt_usage(prompt_id).await;
drop(turn_scope_guard);
self.chat_state_handle.flush();
let total_tokens = self.chat_state_handle.get_total_tokens().await;
let (stop_reason, mut snapshot, completion_kind, structured_output) = match outcome
{
TurnOutcome::Completed {
snapshot,
structured_output,
refusal,
..
} => (
if refusal {
acp::StopReason::Refusal
} else {
acp::StopReason::EndTurn
},
*snapshot,
PromptCompletionKind::Completed,
structured_output,
),
TurnOutcome::Cancelled { category, context } => {
let cancellation_ctx = context.and_then(|v| serde_json::from_value(v).ok());
(
acp::StopReason::Cancelled,
None,
PromptCompletionKind::Cancelled {
category,
context: cancellation_ctx,
},
None,
)
}
TurnOutcome::MaxTurnsReached { limit } => (
acp::StopReason::Cancelled,
None,
PromptCompletionKind::MaxTurnsReached { limit },
None,
),
};
if let Some(snapshot) = snapshot.as_mut() {
self.apply_prompt_modes_to_snapshot(snapshot);
}
Ok(crate::session::commands::PromptTurnOk {
stop_reason,
total_tokens,
turn_snapshot: snapshot,
completion_kind,
structured_output,
usage,
})
}
Err(e) => {
let usage = self.freeze_prompt_usage(prompt_id).await;
drop(turn_scope_guard);
Err(crate::sampling::error::attach_prompt_usage(e, usage))
}
}
}
/// Wait for turn-blocking subagents (up to 120s on the turn task),
/// snapshot, clear sticky. Background children never gate the drain: the
/// prompt report is marked incomplete immediately and their spend reaches
/// the session ledger when they finish.
/// Cancel intentionally skips this multi-second drain (actor-loop safety).
pub(super) async fn freeze_prompt_usage(
&self,
prompt_id: &str,
) -> Option<crate::extensions::notification::PromptUsage> {
const DRAIN: std::time::Duration = std::time::Duration::from_secs(120);
self.freeze_prompt_usage_bounded(prompt_id, DRAIN).await
}
/// [`freeze_prompt_usage`] with an explicit drain bound, for tests.
pub(super) async fn freeze_prompt_usage_bounded(
&self,
prompt_id: &str,
max_wait: std::time::Duration,
) -> Option<crate::extensions::notification::PromptUsage> {
let drain = self
.drain_subagent_usage_for_prompt_bounded(prompt_id, max_wait)
.await;
self.finalize_usage_from_outcome(prompt_id, drain).await
}
/// Waits for turn-blocking folds only.
/// `fail_closed` on timeout or query failure; sticky and `background_live`
/// are report-level only (no ledger mark). Must run on the turn task (not
/// the session actor loop) so folds can land.
pub(super) async fn drain_subagent_usage_for_prompt_bounded(
&self,
prompt_id: &str,
max_wait: std::time::Duration,
) -> UsageDrainOutcome {
const POLL: std::time::Duration = std::time::Duration::from_millis(50);
let deadline = std::time::Instant::now() + max_wait;
loop {
let reply = self.outstanding_reply_for_prompt(prompt_id).await;
match reply.as_ref() {
None => {
tracing::warn!(
prompt_id,
"outstanding subagent query failed; treating usage as incomplete"
);
return UsageDrainOutcome {
fail_closed: true,
background_live: false,
sticky_report: false,
};
}
Some(r) if r.live_ids.is_empty() => {
return UsageDrainOutcome {
fail_closed: false,
background_live: r.background_live,
sticky_report: r.subagent_usage_not_applied,
};
}
Some(r) => {
if std::time::Instant::now() >= deadline {
tracing::warn!(
prompt_id,
count = r.live_ids.len(),
max_wait_ms = max_wait.as_millis() as u64,
"subagent usage drain timed out; usage may under-count"
);
return UsageDrainOutcome {
fail_closed: true,
background_live: r.background_live,
sticky_report: r.subagent_usage_not_applied,
};
}
}
}
tokio::time::sleep(POLL).await;
}
}
pub(super) async fn snapshot_prompt_usage(
&self,
) -> Option<crate::extensions::notification::PromptUsage> {
self.snapshot_prompt_usage_marked(false).await
}
pub(super) async fn snapshot_prompt_usage_marked(
&self,
incomplete: bool,
) -> Option<crate::extensions::notification::PromptUsage> {
match self.chat_state_handle.try_get_prompt_usage().await {
Ok(ledger) => {
let incomplete = incomplete || ledger.as_ref().is_some_and(|l| l.incomplete);
crate::extensions::notification::PromptUsage::project_from_ledger(
ledger.as_ref(),
incomplete,
)
}
Err(()) => {
crate::extensions::notification::PromptUsage::project_from_ledger(None, true)
}
}
}
/// When freeze did not attach: incomplete if billed or may under-count; else omit.
pub(super) async fn error_path_usage_fallback(
&self,
prompt_id: &str,
) -> Option<crate::extensions::notification::PromptUsage> {
let may_undercount = Self::usage_incomplete_from_reply(
self.outstanding_reply_for_prompt(prompt_id).await.as_ref(),
);
match self.chat_state_handle.try_get_prompt_usage().await {
Ok(ledger) => crate::extensions::notification::PromptUsage::for_error_path(
ledger.as_ref(),
may_undercount,
),
Err(()) => crate::extensions::notification::PromptUsage::for_error_path(None, true),
}
}
/// Sticky incomplete for `prompt_id`, or the live pin when `None`.
/// Returns true only if the coordinator acked the mark.
pub(super) async fn mark_subagent_usage_not_applied(&self, prompt_id: Option<&str>) -> bool {
let resolved = prompt_id
.map(str::to_owned)
.or_else(|| self.current_prompt_id.lock().ok().and_then(|g| g.clone()));
let Some(pid) = resolved else {
return false;
};
let Some(tx) = &self.tool_context.subagent_event_tx else {
return false;
};
use kigi_tools::implementations::grok_build::task::types::{
SubagentEvent, SubagentMarkUsageNotAppliedRequest,
};
let (respond_to, ack) = tokio::sync::oneshot::channel();
if tx
.send(SubagentEvent::MarkUsageNotApplied(
SubagentMarkUsageNotAppliedRequest {
prompt_id: pid,
respond_to,
},
))
.is_err()
{
return false;
}
ack.await.is_ok()
}
/// Drain this session's buffered mid-turn monitor events
/// (`drain_owned` — leader mode shares the buffer) into ONE hidden
/// synthetic user message, tagged `SyntheticReason::SystemReminder` so
/// compaction/fork/pruning skip it. Deliberately a bare
/// `push_user_message`, NOT `inject_synthetic_user_message`: the latter
/// persists a `UserMessageChunk` to `updates.jsonl`, which resume
/// replays — the raw XML would render as a user prompt. Clients see
/// monitor events only via the structured `x.ai/monitor_event` channel.
pub(crate) async fn inject_pending_monitor_events(&self) {
let Some(buffer) = &self.tool_context.monitor_event_buffer else {
return;
};
let mine = kigi_tools::implementations::grok_build::task::types::drain_owned(
buffer,
Some(self.session_info.id.0.as_ref()),
);
if mine.is_empty() {
return;
}
let Some(body) = kigi_tools::reminders::task_completion::format_monitor_events(
&mine,
Some(&self.tool_context.task_output_tool_name),
) else {
return;
};
let wrapped = kigi_tools::reminders::wrap_reminder(&body);
self.chat_state_handle
.push_user_message(ConversationItem::system_reminder(wrapped));
tracing::info!(
session_id = % self.session_info.id.0, count = mine.len(),
"injected mid-turn monitor events as hidden synthetic user message"
);
}
/// Per-turn hook called from the event-loop completion handler
/// after every turn finishes. Two terminal branches when the
/// goal is `Active` (`goal_active_now == true`):
///
/// 1. **Success.** Reset `goal_continuation_streak` to 0, then call
/// `maybe_queue_goal_continuation`. That helper verifies any
/// pending completion via its turn-end drain, queues the
/// continuation reminder if the goal is still `Active`, and runs
/// the stop-detector to select the nudge flavor (generic vs.
/// bail-specific) and emit `Event::GoalPrematureStopDetected`.
/// 2. **Non-success.** Increment `goal_continuation_streak`. At
/// [`GOAL_CONTINUATION_BACKOFF_THRESHOLD`] consecutive hits,
/// reset the streak and auto-pause with
/// `GoalPauseReason::BackOff`. No continuation is queued on this path: an
/// infra-error / cancelled turn rarely carries a deliberate
/// turn-final message, and stop-detection lives on the success
/// path inside `maybe_queue_goal_continuation`.
///
/// When the goal is not `Active` (`goal_active_now == false` —
/// the doom-loop / infra-error branches in the event loop ran
/// before this method and already transitioned the goal out of
/// Active), both branches are skipped: neither streak moves and the
/// existing pause cause is preserved.
///
/// `goal_blocked_streak` is never touched here — blocked attempts end
/// their turns successfully, so a per-turn reset would make the
/// 3-streak pause unreachable. It resets on goal create / resume /
/// clear and on a `completed: true` `update_goal`.
pub(crate) async fn handle_turn_end(&self, turn_succeeded: bool) {
let goal_active_now = laziness_injection_active(
self.goal_harness_enabled(),
self.goal_tracker.lock().status(),
);
if turn_succeeded && goal_active_now {
self.goal_continuation_streak
.store(0, std::sync::atomic::Ordering::Relaxed);
self.maybe_queue_goal_continuation().await;
return;
}
if !turn_succeeded && goal_active_now {
let current_tokens = self.chat_state_handle.get_total_tokens().await as i64;
if self.enforce_goal_token_budget(current_tokens).await {
return;
}
let streak = self
.goal_continuation_streak
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
+ 1;
if streak >= GOAL_CONTINUATION_BACKOFF_THRESHOLD {
self.goal_continuation_streak
.store(0, std::sync::atomic::Ordering::Relaxed);
self.auto_pause_goal_if_active(
crate::session::goal_tracker::GoalPauseReason::BackOff,
)
.await;
self.send_slash_command_output(&format!(
"Goal auto-paused after {GOAL_CONTINUATION_BACKOFF_THRESHOLD} consecutive \
non-completing turns. The model is not making progress. \
Use /goal resume to retry or /goal clear to abandon."
))
.await;
}
}
}
/// Wraps `process_conversation_turn` with auto-recovery for agents that opt in.
///
/// Agents with a `completion_requirement` in their definition require the model
/// to call a specific tool before finishing. If a prompt turn ends without that
/// tool having been called, this method injects the recovery prompt and re-runs
/// the turn with exponential backoff.
///
/// Agents without `completion_requirement` bypass this entirely.
#[tracing::instrument(
name = "session.process_conversation_turn_with_recovery",
skip_all,
err,
fields(req_id = %req_id, session_id = %self.session_info.id.0)
)]
pub(super) async fn process_conversation_turn_with_recovery(
self: &Arc<Self>,
req_id: &str,
json_schema: Option<serde_json::Value>,
) -> Result<TurnOutcome, acp::Error> {
let _ = self.compaction.auto_compact_suppressed.compare_exchange(
crate::session::compaction_config::SUPPRESS_TURN,
crate::session::compaction_config::SUPPRESS_NONE,
std::sync::atomic::Ordering::Relaxed,
std::sync::atomic::Ordering::Relaxed,
);
let agent_ref = self.agent.borrow();
let completion_req = match agent_ref.completion_requirement() {
Some(req) => req,
None => {
return self.process_conversation_turn(req_id, json_schema).await;
}
};
let recovery = match &completion_req.recovery {
Some(r) => r.clone(),
None => {
return self.process_conversation_turn(req_id, json_schema).await;
}
};
let required_tool = completion_req.tool.clone();
let recovery_prompt = completion_req.reminder.clone();
let mut result = self
.process_conversation_turn(req_id, json_schema.clone())
.await;
if matches!(result, Ok(TurnOutcome::MaxTurnsReached { .. })) {
return result;
}
if let Ok(TurnOutcome::Completed {
ref tools_called, ..
}) = result
&& tools_called.iter().any(|name| name == &required_tool)
{
tracing::info!(
"Completion requirement satisfied (tool '{}' called) for session {}",
required_tool,
self.session_info.id.0,
);
return result;
}
let mut attempt = 0u32;
loop {
attempt += 1;
let error_desc = match &result {
Ok(_) => "Agent finished without completing required task".into(),
Err(e) => format!("{e:?}"),
};
if attempt > recovery.max_retries {
tracing::error!(
"Auto-recovery exhausted after {attempt} attempts for session {}: {error_desc}",
self.session_info.id.0,
);
self.send_xai_notification(XaiSessionUpdate::AutoRecoveryExhausted {
attempts: attempt,
error: error_desc,
})
.await;
return result;
}
let delay_ms = std::cmp::min(
recovery.base_delay_ms * 2u64.pow(attempt.saturating_sub(1)),
recovery.max_delay_ms,
);
let delay = std::time::Duration::from_millis(delay_ms);
tracing::warn!(
"Auto-recovery attempt {}/{} for session {}: {error_desc}. Retrying in {}ms",
attempt,
recovery.max_retries,
self.session_info.id.0,
delay.as_millis(),
);
self.send_xai_notification(XaiSessionUpdate::AutoRecoveryStarted {
attempt,
max_retries: recovery.max_retries,
error: error_desc,
delay_ms: delay.as_millis() as u64,
})
.await;
sleep(delay).await;
let recovery_message = ConversationItem::auto_recovery(recovery_prompt.clone());
self.chat_state_handle.push_user_message(recovery_message);
result = self.process_conversation_turn(req_id, None).await;
if matches!(result, Ok(TurnOutcome::MaxTurnsReached { .. })) {
return result;
}
if let Ok(TurnOutcome::Completed {
ref tools_called, ..
}) = result
&& tools_called.iter().any(|name| name == &required_tool)
{
tracing::info!(
"Completion requirement satisfied after {} recovery attempt(s) \
(tool '{}' called) for session {}",
attempt,
required_tool,
self.session_info.id.0,
);
return result;
}
}
}
/// Compute the first-turn memory reminder, if one should be injected.
///
/// A block persisted by an earlier session segment (a prior `--resume`
/// process, or a turn before a compaction) is reused verbatim — see
/// [`conversation_has_memory_context`] for why re-searching is harmful.
///
/// [`conversation_has_memory_context`]: crate::session::helpers::memory_context::conversation_has_memory_context
pub(crate) async fn first_turn_memory_reminder(&self) -> Option<String> {
if self
.memory
.context_injected
.load(std::sync::atomic::Ordering::Relaxed)
{
return None;
}
self.memory
.context_injected
.store(true, std::sync::atomic::Ordering::Relaxed);
if !self.memory.initial_injection_config.enabled {
tracing::info!(
target : kigi_log::memory_log::TARGET,
"MEMORY_INJECT: first-turn injection disabled by config"
);
return None;
}
let (Some(storage), Some(params)) =
(self.memory.storage(), self.memory.backend_params.as_ref())
else {
return None;
};
let conversation = self.chat_state_handle.get_conversation().await;
if crate::session::helpers::memory_context::conversation_has_memory_context(&conversation) {
tracing::info!(
target : kigi_log::memory_log::TARGET,
"MEMORY_INJECT: existing memory-context block present in system message -- skipping re-injection to preserve prompt cache"
);
return None;
}
use kigi_tools::types::memory_backend::MemoryBackend as _;
let (injection_params, configured_min_score) =
build_initial_injection_backend_params(params, &self.memory.initial_injection_config);
let backend = crate::session::memory::MemoryBackendImpl::from_session_params(
storage,
&injection_params,
);
let raw_query =
crate::session::helpers::session_compact::extract_last_real_user_query(&conversation)
.unwrap_or_default();
let was_greeting = raw_query.is_empty()
|| raw_query.len() < 20
|| crate::session::helpers::memory_context::is_greeting(&raw_query);
let query = if was_greeting {
"project conventions preferences architecture".to_string()
} else {
raw_query
};
let inject_start = std::time::Instant::now();
let inject_results = backend.search(&query, 6, configured_min_score).await.ok();
let result_count = inject_results.as_ref().map_or(0, |r| r.len());
let top_score = inject_results
.as_ref()
.and_then(|r| r.first())
.map_or(0.0, |r| r.score);
let total_snippet_chars: usize = inject_results
.as_ref()
.map_or(0, |r| r.iter().map(|s| s.snippet.len()).sum());
tracing::info!(
target : kigi_log::memory_log::TARGET, configured_min_score,
"MEMORY_INJECT_SEARCH: results={result_count}"
);
inject_results.and_then(|results| {
crate::session::helpers::memory_context::format_memory_reminder(&results)
})
}
/// Inspect `tool_calls` for a `StructuredOutput` call and decide the turn's
/// next step, pushing the call's `tool_result` (correction / retry error /
/// terminal) as a side effect. Validates the args against `validator` and
/// bumps `retries` on a non-conforming retry.
async fn handle_structured_output_tool_call(
&self,
tool_calls: &mut Vec<kigi_sampling_types::conversation::ToolCall>,
validator: &Result<jsonschema::Validator, String>,
retries: &mut u32,
) -> StructuredOutputStep {
let Some(pos) = tool_calls
.iter()
.position(|tc| tc.name == STRUCTURED_OUTPUT_TOOL)
else {
return StructuredOutputStep::Proceed;
};
if tool_calls.len() > 1 {
for tc in tool_calls
.iter()
.filter(|tc| tc.name == STRUCTURED_OUTPUT_TOOL)
{
self.chat_state_handle
.push_tool_result(ConversationItem::tool_result(
tc.id.as_ref().to_owned(),
"Call StructuredOutput alone, exactly once, after all other tools finish.",
));
}
tool_calls.retain(|tc| tc.name != STRUCTURED_OUTPUT_TOOL);
return StructuredOutputStep::Proceed;
}
let call_id = tool_calls[pos].id.as_ref().to_owned();
let validated = validate_structured_output(validator, &tool_calls[pos].arguments);
if let Err(err) = &validated
&& *retries < STRUCTURED_OUTPUT_MAX_RETRIES
{
*retries += 1;
self.chat_state_handle
.push_tool_result(ConversationItem::tool_result(
call_id,
format!("{err}\nFix the arguments and call StructuredOutput again."),
));
return StructuredOutputStep::Retry;
}
self.chat_state_handle
.push_tool_result(ConversationItem::tool_result(
call_id,
match &validated {
Ok(_) => "Structured output accepted.".to_string(),
Err(err) => err.clone(),
},
));
StructuredOutputStep::Complete(validated)
}
/// Shared turn-completion bookkeeping (plan cleanup, signals snapshot +
/// persistence, BigQuery turn delta, feedback prompt). Runs identically for
/// the native and StructuredOutput-tool completion paths. Returns the
/// turn-end snapshot for `TurnOutcome::Completed`.
async fn finalize_turn_bookkeeping(
&self,
req_id: &str,
conv_turn_start: std::time::Instant,
turn_span_totals: &TurnSpanTotals,
model_fingerprint: Option<String>,
) -> Option<TurnDeltaSnapshot> {
self.emit_turn_end_plan_cleanup().await;
self.signals_handle().record_turn_complete();
let mut snapshot = self.signals_handle().take_turn_end_snapshot().await;
if let Some(snap) = snapshot.as_mut() {
self.apply_prompt_modes_to_snapshot(snap);
snap.turn_input_tokens = turn_span_totals.input_tokens.max(0) as u64;
snap.turn_output_tokens = turn_span_totals.output_tokens.max(0) as u64;
snap.turn_cached_input_tokens = turn_span_totals.cache_read_tokens.max(0) as u64;
for pr in &snap.delta.prs_created_this_turn {}
}
if let Some(snap) = snapshot.as_ref() {
let _ = self
.notifications
.persistence_tx
.send(PersistenceMsg::Signals(snap.current.clone()));
}
if let Some(request) = self
.feedback_manager
.maybe_request_feedback(Some(req_id.to_string()))
.await
{
self.send_feedback_notification(request).await;
}
snapshot
}
#[tracing::instrument(
name = "session.process_conversation_turn",
skip_all,
err,
fields(
session_id = %self.session_info.id.0,
model_id,
turn_tool_count,
turn_model_calls,
input_tokens = tracing::field::Empty,
output_tokens = tracing::field::Empty,
cache_read_tokens = tracing::field::Empty,
stop_reason = tracing::field::Empty,
response.has_tool_call = tracing::field::Empty,
request_id = tracing::field::Empty,
ttft_ms = tracing::field::Empty,
mcp_server.name = tracing::field::Empty,
mcp_tool.name = tracing::field::Empty,
agent.name = tracing::field::Empty,
skill.name = tracing::field::Empty,
query_source = tracing::field::Empty,
effort = tracing::field::Empty,
attempt = tracing::field::Empty,
parent_agent_id = tracing::field::Empty,
)
)]
async fn process_conversation_turn(
self: &Arc<Self>,
req_id: &str,
json_schema: Option<serde_json::Value>,
) -> Result<TurnOutcome, acp::Error> {
let conv_turn_start = std::time::Instant::now();
self.maybe_refresh_model_metadata_on_resume().await;
self.maybe_compact_on_model_switch().await;
self.chat_state_handle
.record_turn_start(chrono::Utc::now().timestamp_millis());
{
let span = tracing::Span::current();
if let Some(agent) = self.active_agent_type.lock().clone() {
span.record("agent.name", agent.as_str());
}
if let Some(skill) = self.active_skill.lock().clone() {
span.record("skill.name", skill.as_str());
}
span.record(
"query_source",
if self.startup_hints.is_subagent {
"subagent"
} else {
"main"
},
);
if let Some(parent) = self.startup_hints.parent_session_id.as_deref() {
span.record("parent_agent_id", parent);
}
}
if let Some(cfg) = self.chat_state_handle.get_sampling_config().await {
let span = tracing::Span::current();
span.record("model_id", cfg.model.as_str());
if let Some(effort) = cfg.reasoning_effort {
span.record("effort", effort.as_str());
}
}
let tool_prep_start = std::time::Instant::now();
let (tool_definitions, mcp_wait_ms) = self.prepare_tool_definitions_timed().await;
let total_prep_ms = tool_prep_start.elapsed().as_millis() as u64;
kigi_log::unified_log::info(
"shell.turn.tool_prep_done",
Some(self.session_info.id.0.as_ref()),
Some(serde_json::json!(
{ "tool_count" : tool_definitions.len(), "mcp_wait_ms" : mcp_wait_ms,
"total_prep_ms" : total_prep_ms, "elapsed_since_turn_start_ms" :
conv_turn_start.elapsed().as_millis() as u64, }
)),
);
self.record_turn_model().await;
let mut metrics_drop_guard = TurnMetrics::new();
let mut turn_tools_called: Vec<String> = Vec::new();
let mut tool_turn_count: usize = 1;
let mut loop_index: u32 = 0;
let mut todo_gate_fires: u32 = 0;
let mut auth_retry_schedule = AuthRetrySchedule::new();
let mut turn_span_totals = TurnSpanTotals::default();
let mut model_fingerprint: Option<String> = None;
let mut structured_output_retries: u32 = 0;
let structured_output_validator = json_schema.as_ref().map(|schema| {
jsonschema::validator_for(schema).map_err(|e| format!("invalid output schema: {e}"))
});
let schema_ok = matches!(structured_output_validator, Some(Ok(_)));
let native_backend = if json_schema.is_some() {
match self.chat_state_handle.get_sampling_config().await {
Some(c) => c.api_backend.supports_native_schema(),
None => {
tracing::warn!(
"structured output: no sampling config; using StructuredOutput tool"
);
false
}
}
} else {
false
};
let structured_output_native = schema_ok && native_backend;
let structured_output_tool = schema_ok && !native_backend;
if structured_output_tool {
self.push_system_reminder(
"A response schema is required. After any tool use, call the \
`StructuredOutput` tool exactly once with your final answer as its \
arguments; do not return the answer as text.",
);
}
loop {
self.emit_event(crate::session::events::Event::LoopStarted { loop_index });
loop_index += 1;
self.drain_pending_interjections().await;
self.flush_pending_skill_reminders().await;
self.inject_pending_monitor_events().await;
let memory_reminder = self.first_turn_memory_reminder().await;
if memory_reminder.is_some() {
self.memory
.injection_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
tracing::info!(
target : kigi_log::memory_log::TARGET,
"MEMORY_INJECT: first-turn memory context injected"
);
}
self.maybe_inject_mcp_reminder().await;
if self.two_pass_active()
&& !self.compaction.prefire.has_cache()
&& self.should_prefire_two_pass().await
&& self.compaction.prefire.try_begin()
{
let actor = std::sync::Arc::clone(self);
let handle = tokio::task::spawn_local(async move {
actor.run_prefire_pass1().await;
});
self.compaction.prefire.set_handle(handle);
}
if let Some(trigger_info) = self.check_auto_compact_needed().await
&& let Err(e) = self.run_compact_only(trigger_info).await
{
tracing::error!(error = % e, "Pre-sampling auto-compaction failed");
}
let use_backend_search =
self.agent.borrow().backend_search_enabled() && self.supports_backend_search.get();
tracing::debug!(use_backend_search, "backend_search: turn tool resolution");
let mut effective_tools: Vec<ToolSpec> =
if let Some(ref override_tools) = self.forked_tool_override {
override_tools.clone()
} else {
self.turn_base_tool_specs(&tool_definitions)
};
if structured_output_tool && let Some(schema) = json_schema.clone() {
effective_tools.push(ToolSpec {
name: STRUCTURED_OUTPUT_TOOL.to_string(),
description: Some(
"Return your final answer as JSON matching the required schema. \
Call this exactly once, at the end."
.to_string(),
),
parameters: schema,
});
}
let build_req_start = std::time::Instant::now();
let request = self
.chat_state_handle
.build_request(
effective_tools,
memory_reminder,
self.memory.is_enabled(),
None,
self.session_info.id.to_string(),
req_id.to_owned(),
)
.await
.expect("chat state actor should be alive");
kigi_log::unified_log::debug(
"shell.turn.build_request_done",
Some(self.session_info.id.0.as_ref()),
Some(serde_json::json!(
{ "build_request_ms" : build_req_start.elapsed().as_millis() as
u64, "loop_index" : loop_index, }
)),
);
let mut request = request;
request.x_grok_session_id = Some(self.session_info.id.to_string());
request.x_grok_turn_idx =
Some(self.chat_state_handle.get_prompt_index().await.to_string());
request.x_grok_agent_id = Some(crate::util::agent_id::agent_id());
if request.x_grok_deployment_id.is_none() {
request.x_grok_deployment_id = crate::managed_config::resolve_deployment_id(
crate::managed_config::resolve_deployment_key().as_deref(),
);
}
if structured_output_native {
request.json_schema = json_schema.clone();
}
if use_backend_search {
request.hosted_tools = self.agent.borrow().hosted_tools().to_vec();
}
self.emit_event(crate::session::events::Event::PhaseChanged {
phase: crate::session::events::Phase::WaitingForModel,
});
self.observability_bridge
.emit(
kigi_tool_protocol::session_event::SessionEvent::PhaseChanged {
phase: kigi_tool_protocol::session_event::SessionPhase::Sampling,
},
)
.await;
kigi_log::unified_log::info(
"shell.turn.inference_start",
Some(self.session_info.id.0.as_ref()),
Some(serde_json::json!(
{ "loop_index" : loop_index, "elapsed_since_turn_start_ms" :
conv_turn_start.elapsed().as_millis() as u64, }
)),
);
let model_timer = std::time::Instant::now();
let (response, latency) = match self.run_turn_via_sampler(request.clone()).await? {
SamplerTurnOutcome::Response(r, latency) => (r, latency),
SamplerTurnOutcome::CompactAndResubmit => {
auth_retry_schedule.reset();
continue;
}
SamplerTurnOutcome::RefreshAuthAndResubmit => {
if let Some((attempt, delay)) = auth_retry_schedule.next_delay() {
let delay_ms = delay.as_millis() as u64;
tracing::warn!(
attempt,
delay_ms,
"auth 401 retry: backing off before resubmit"
);
kigi_log::unified_log::warn(
"shell.turn.auth_retry_backoff",
Some(self.session_info.id.0.as_ref()),
Some(serde_json::json!(
{ "loop_index" : loop_index, "attempt" : attempt,
"max_retries" : AuthRetrySchedule::MAX_RETRIES, "delay_ms" :
delay_ms, }
)),
);
self.send_xai_notification(XaiSessionUpdate::RetryState(
crate::extensions::notification::RetryState::Retrying {
attempt,
max_retries: AuthRetrySchedule::MAX_RETRIES,
reason: "Re-authenticated after 401; retrying request".to_string(),
},
))
.await;
sleep(delay).await;
continue;
}
let msg = format!(
"Auth recovery succeeded but inference request was \
still rejected (401) after {} retries",
AuthRetrySchedule::MAX_RETRIES
);
tracing::error!(msg);
return Err(acp::Error::internal_error().data(
crate::sampling::error::error_data_with_status(msg, Some(401)),
));
}
};
auth_retry_schedule.reset();
let model_elapsed_ms = model_timer.elapsed().as_millis() as u64;
let usage = response.usage.as_ref();
let prompt_tokens = usage.map(|u| u.prompt_tokens);
let cached_prompt_tokens = usage.map(|u| u.cached_prompt_tokens);
let completion_tokens = usage.map(|u| u.completion_tokens);
let reasoning_tokens = usage.map(|u| u.reasoning_tokens);
let ttft_ms = latency.time_to_first_token_ms;
let tokens_per_sec = match completion_tokens {
Some(ct) if ct > 0 => {
let decode_ms = match ttft_ms {
Some(ttft) if model_elapsed_ms > ttft => model_elapsed_ms - ttft,
_ => model_elapsed_ms,
};
(decode_ms > 0).then(|| {
let tps = f64::from(ct) * 1000.0 / decode_ms as f64;
(tps * 10.0).round() / 10.0
})
}
_ => None,
};
kigi_log::unified_log::info(
"shell.turn.inference_done",
Some(self.session_info.id.0.as_ref()),
Some(serde_json::json!(
{ "loop_index" : loop_index, "model_elapsed_ms" :
model_elapsed_ms, "elapsed_since_turn_start_ms" : conv_turn_start
.elapsed().as_millis() as u64, "ttft_ms" : ttft_ms, "itl_p50_ms"
: latency.itl_p50_ms, "attempts" : latency.attempts,
"prompt_tokens" : prompt_tokens, "cached_prompt_tokens" :
cached_prompt_tokens, "completion_tokens" : completion_tokens,
"reasoning_tokens" : reasoning_tokens, "tokens_per_sec" :
tokens_per_sec, }
)),
);
if let Some(usage) = response.usage.as_ref() {
self.chat_state_handle
.record_token_usage(u64::from(usage.total_tokens));
self.send_available_commands_update().await;
}
turn_span_totals.record(&tracing::Span::current(), &response);
let _ = self.compaction.auto_compact_suppressed.compare_exchange(
crate::session::compaction_config::SUPPRESS_UNTIL_SUCCESS,
crate::session::compaction_config::SUPPRESS_NONE,
std::sync::atomic::Ordering::Relaxed,
std::sync::atomic::Ordering::Relaxed,
);
let model_duration_ms = model_timer.elapsed().as_millis() as u64;
{
let model_id = self.current_model_id().await;
}
self.record_response_token_usage(&response, Some(model_duration_ms));
let mut tool_calls = response.tool_calls().to_vec();
metrics_drop_guard.record_model_response(tool_calls.len());
if let Some(fp) = response
.assistant()
.and_then(|a| a.model_fingerprint.clone())
{
model_fingerprint = Some(fp);
}
let fallback_text = response.fallback_text();
let stop_reason = response.stop_reason;
let response_is_empty = response.is_empty();
let turn_refused = stop_reason == Some(kigi_sampling_types::StopReason::ContentFilter);
let refusal_explanation = response.stop_message.clone();
let final_answer_text = json_schema.is_some().then(|| response.assistant_text());
for item in response.items {
match item {
kigi_sampling_types::ConversationItem::Assistant(_) => {
self.record_assistant_response(item).await;
}
_ => {
self.chat_state_handle.push_tool_result(item);
}
}
}
if let Some(text) = fallback_text {
tracing::warn!(
text_len = text.len(),
"emitting fallback AgentMessageChunk — no text chunks were streamed"
);
self.send_update(
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
acp::ContentBlock::Text(acp::TextContent::new(text)),
)),
None,
)
.await;
}
if turn_refused && response_is_empty {
let mut notice = "The model provider refused to generate a response \
for this turn (content filter)."
.to_string();
if let Some(explanation) = refusal_explanation.as_deref() {
notice.push_str("\n\nProvider explanation: ");
notice.push_str(explanation);
}
tracing::warn!(
has_explanation = refusal_explanation.is_some(),
"model response was a provider refusal — emitting notice chunk"
);
self.send_update(
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
acp::ContentBlock::Text(acp::TextContent::new(notice)),
)),
None,
)
.await;
}
if tool_calls.is_empty() {
if !schema_ok
&& !turn_refused
&& let Some(gate_cfg) = self.todo_gate_policy()
{
let collected = self.collect_todo_gate_input(req_id).await;
let input = collected.as_input();
if let TodoGateDecision::Nudge { reminder, reason } = evaluate_todo_gate(&input)
{
if todo_gate_fires < gate_cfg.max_fires_per_prompt {
todo_gate_fires += 1;
tracing::info!(
prompt_id = % req_id, pending = ? input.pending,
unbacked_in_progress = ? input.in_progress_unbacked,
backed_in_progress = ? input.in_progress_backed,
backing_task_count = input.backing_task_count,
todo_gate_fires, reason = reason.as_str(),
"turn-end TodoGate: nudging model to advance remaining todos"
);
self.events
.emit(crate::session::events::Event::TodoGateFired {
fires: todo_gate_fires,
pending: input.pending.len(),
in_progress: input.in_progress_unbacked.len()
+ input.in_progress_backed.len(),
reason: reason.as_str(),
});
let rendered = self
.tool_bridge_handle()
.render_prompt(&reminder, &serde_json::json!({}))
.await
.unwrap_or(reminder);
self.push_system_reminder(&rendered);
continue;
}
let cap = gate_cfg.max_fires_per_prompt;
tracing::warn!(
prompt_id = % req_id, todo_gate_cap = cap,
"turn-end TodoGate: exhausted retries, falling through"
);
self.events
.emit(crate::session::events::Event::TodoGateExhausted {
pending: input.pending.len(),
});
self.push_system_reminder(&format!(
"The agent attempted to end this turn {cap} times \
with todos still pending or in_progress. Falling through \
to user. If you want autonomous progress, prompt the agent \
to continue explicitly, or clean up the todo list."
));
}
}
if self.drain_pending_interjections().await {
tracing::info!("Drained interjection(s) before turn completion — continuing");
continue;
}
let snapshot = self
.finalize_turn_bookkeeping(
req_id,
conv_turn_start,
&turn_span_totals,
model_fingerprint.clone(),
)
.await;
if self.drain_pending_interjections().await {
tracing::info!(
"Drained late interjection(s) during turn-end bookkeeping — continuing"
);
continue;
}
let structured_output = match (
structured_output_validator.as_ref(),
final_answer_text.as_ref(),
) {
(Some(validator), Some(text)) => {
Some(validate_structured_output(validator, text))
}
_ => None,
};
return Ok(TurnOutcome::Completed {
snapshot: Box::new(snapshot),
tools_called: turn_tools_called,
structured_output,
refusal: turn_refused,
});
}
if structured_output_tool && let Some(validator) = structured_output_validator.as_ref()
{
match self
.handle_structured_output_tool_call(
&mut tool_calls,
validator,
&mut structured_output_retries,
)
.await
{
StructuredOutputStep::Complete(validated) => {
turn_tools_called.push(STRUCTURED_OUTPUT_TOOL.to_string());
let snapshot = self
.finalize_turn_bookkeeping(
req_id,
conv_turn_start,
&turn_span_totals,
model_fingerprint.clone(),
)
.await;
return Ok(TurnOutcome::Completed {
snapshot: Box::new(snapshot),
tools_called: turn_tools_called,
structured_output: Some(validated),
refusal: false,
});
}
StructuredOutputStep::Retry => continue,
StructuredOutputStep::Proceed => {}
}
}
for tc in &tool_calls {
if let Some((server, tool)) =
crate::session::mcp_servers::parse_mcp_tool_name(&tc.name)
{
let span = tracing::Span::current();
span.record("mcp_server.name", server.as_str());
span.record("mcp_tool.name", tool.as_str());
}
turn_tools_called.push(tc.name.clone());
}
let tool_call_responses: Vec<ToolCallResponse> = tool_calls
.into_iter()
.map(|tc| ToolCallResponse {
id: tc.id.as_ref().to_owned(),
kind: "function".to_string(),
function: crate::sampling::types::ToolCallFunction {
name: tc.name,
arguments: tc.arguments.as_ref().to_owned(),
},
})
.collect();
self.emit_event(crate::session::events::Event::PhaseChanged {
phase: crate::session::events::Phase::ToolExecution,
});
self.observability_bridge
.emit(
kigi_tool_protocol::session_event::SessionEvent::PhaseChanged {
phase: kigi_tool_protocol::session_event::SessionPhase::ToolExecution,
},
)
.await;
let execute_tool_calls_result = self.execute_tool_calls(tool_call_responses).await;
match execute_tool_calls_result {
Ok(ToolLoop::PermissionReject { tool_name, reason }) => {
return Ok(TurnOutcome::Cancelled {
category: Some(
crate::session::events::CancellationCategory::PermissionRejected,
),
context: Some(serde_json::json!(
{ "tool_name" : tool_name, "reason" : reason, }
)),
});
}
Ok(ToolLoop::HookDenied { .. }) => {}
Ok(ToolLoop::Cancelled) => {
return Ok(TurnOutcome::Cancelled {
category: Some(
crate::session::events::CancellationCategory::PermissionCancelled,
),
context: None,
});
}
Ok(ToolLoop::FollowupMessage(followup_message)) => {
self.add_followup_message_as_user_turn(&followup_message)
.await;
continue;
}
_ => {}
}
let next_turn = tool_turn_count + 1;
if let Some(limit) = self.max_turns
&& next_turn > limit
{
tracing::info!(
session_id = % self.session_info.id, tool_turn_count, limit,
"max-turns limit reached, stopping"
);
return Ok(TurnOutcome::MaxTurnsReached { limit });
}
tool_turn_count = next_turn;
if let Some(trigger_info) = self.check_preflight_overflow().await {
if let Err(e) = self.run_compact_only(trigger_info).await {
tracing::error!(error = % e, "Preflight overflow compaction failed");
}
continue;
}
}
}
}
/// Backoff schedule for resubmits after a *successful* 401 auth recovery
/// (fresh token minted, request to be re-sent).
///
/// Two hard-won invariants, both regressions from the silent-hang incident
/// where a turn froze 16m40s and then 11.6 days (user-cancelled at 27min):
///
/// - **Delays must be 1s/2s/4s.** `tokio_retry::ExponentialBackoff::
/// from_millis(base)` raises `base` to the attempt number, so the base must
/// stay small: `from_millis(1000)` yields 1000ⁿ ms = 1s → 16m40s → 11.57
/// days. `from_millis(2).factor(500)` yields 2ⁿ × 500ms = 1s, 2s, 4s.
/// - **The schedule is per-incident, not per-turn.** A long turn can span
/// several hourly gateway token rotations; each rotation is an independent
/// 401→refresh→retry event. Without `reset()` after a successful response,
/// the third rotation of one turn would land on the last (largest) delay
/// and the fourth would fail the turn outright.
struct AuthRetrySchedule {
delays: std::iter::Take<ExponentialBackoff>,
attempt: u32,
}
impl AuthRetrySchedule {
/// Consecutive post-recovery 401s tolerated before the turn fails.
const MAX_RETRIES: u32 = 3;
fn new() -> Self {
Self {
delays: ExponentialBackoff::from_millis(2)
.factor(500)
.max_delay(std::time::Duration::from_secs(10))
.take(Self::MAX_RETRIES as usize),
attempt: 0,
}
}
/// Next `(attempt_number, delay)` (1-indexed), or `None` once exhausted.
fn next_delay(&mut self) -> Option<(u32, std::time::Duration)> {
let delay = self.delays.next()?;
self.attempt += 1;
Some((self.attempt, delay))
}
/// A successful model response closes the incident: restart the schedule
/// so the next token rotation starts back at the shortest delay.
fn reset(&mut self) {
*self = Self::new();
}
}
#[cfg(test)]
mod auth_retry_schedule_tests {
use super::AuthRetrySchedule;
use std::time::Duration;
/// Pins the exact schedule. Guards against the `from_millis(1000)`
/// footgun (baseⁿ semantics): that spelling produced sleeps of 1s,
/// 16m40s, and 11.57 days, observed in the field as a silent
/// ~27-minute hang in `waiting_model` that the user had to cancel.
#[test]
fn schedule_is_one_two_four_seconds_then_exhausted() {
let mut schedule = AuthRetrySchedule::new();
let steps: Vec<_> = std::iter::from_fn(|| schedule.next_delay()).collect();
assert_eq!(
steps,
vec![
(1, Duration::from_secs(1)),
(2, Duration::from_secs(2)),
(3, Duration::from_secs(4)),
],
);
assert_eq!(
schedule.next_delay(),
None,
"must exhaust after MAX_RETRIES"
);
}
/// Each successful response must restart the schedule: hourly token
/// rotations within one long turn are independent incidents, so they
/// must not escalate toward exhaustion (turn failure).
#[test]
fn reset_restarts_delays_and_attempt_numbering() {
let mut schedule = AuthRetrySchedule::new();
schedule.next_delay();
schedule.next_delay();
schedule.reset();
assert_eq!(schedule.next_delay(), Some((1, Duration::from_secs(1))));
}
}
#[cfg(test)]
mod user_echo_broadcast_tests {
use super::{UserEchoMode, user_echo_mode};
/// Notification-drain: persisted (rewind/fork count user-chunk runs as
/// turn boundaries) but never broadcast live; the pager hides it via the
/// `hideFromScrollback` chunk meta.
#[test]
fn notification_drain_turn_is_persist_only() {
assert_eq!(
user_echo_mode("notifications-019e0000-0000-7000-8000-0000000000aa"),
UserEchoMode::PersistOnly
);
}
/// Real user prompts, cron (`/loop`) fires, and other turns still broadcast
/// live so multi-client / dashboard viewers stay in sync.
#[test]
fn user_and_cron_turns_broadcast_live() {
assert_eq!(user_echo_mode("my-prompt"), UserEchoMode::Broadcast);
assert_eq!(
user_echo_mode("scheduler-fired-abc"),
UserEchoMode::Broadcast
);
assert_eq!(
user_echo_mode("task-completed-bg-1"),
UserEchoMode::Broadcast
);
assert_eq!(
user_echo_mode("subagent-completed-xyz"),
UserEchoMode::Broadcast
);
}
/// Interject-fallback turns are persist-only: every pane already rendered
/// the text from the `x.ai/session/interjection` broadcast, so a live
/// echo would duplicate the block.
#[test]
fn interject_fallback_turn_is_persist_only() {
assert_eq!(
user_echo_mode("interject-fallback-019e24b7"),
UserEchoMode::PersistOnly
);
}
}
#[cfg(test)]
mod structured_output_validation_tests {
use super::validate_structured_output;
fn validator() -> Result<jsonschema::Validator, String> {
let schema = serde_json::json!(
{ "type" : "object", "properties" : { "name" : { "type" : "string" }, "age" :
{ "type" : "integer" } }, "required" : ["name", "age"],
"additionalProperties" : false, }
);
jsonschema::validator_for(&schema).map_err(|e| e.to_string())
}
#[test]
fn accepts_conforming_json() {
let v = validate_structured_output(&validator(), r#"{"name":"alice","age":30}"#).unwrap();
assert_eq!(v["name"], "alice");
}
#[test]
fn rejects_non_json() {
let err = validate_structured_output(&validator(), "not json").unwrap_err();
assert!(err.starts_with("model output was not valid JSON: "));
}
#[test]
fn rejects_schema_violation() {
let err = validate_structured_output(&validator(), r#"{"name":"alice"}"#).unwrap_err();
assert!(err.starts_with("output does not match the required schema: "));
}
#[test]
fn surfaces_invalid_schema_error() {
let bad: Result<jsonschema::Validator, String> = Err("invalid output schema: boom".into());
let err = validate_structured_output(&bad, r#"{"name":"alice","age":1}"#).unwrap_err();
assert_eq!(err, "invalid output schema: boom");
}
}