Skip to main content

kcode_kennedy_sessions/
lib.rs

1//! Kennedy's complete logical session lifecycle and agent orchestration.
2
3#![forbid(unsafe_code)]
4
5mod services;
6#[cfg(test)]
7mod tests;
8
9pub use kcode_kennedy_session_objects::ResolvedObject;
10pub use kcode_kennedy_turn_admission::PendingTurnAdmission;
11pub use kcode_telegram_session_coordinator::validate_file_name as validate_delivery_file_name;
12pub use services::{Api as Service, LocalServices as Capabilities};
13
14use std::{
15    collections::{BTreeMap, BTreeSet, HashMap},
16    future::Future,
17    sync::{Arc, Weak},
18    time::{Duration, Instant},
19};
20
21use anyhow::Context as _;
22use chrono::{DateTime, Utc};
23use kcode_agent_runtime::SessionHost as _;
24use kcode_commit_session::{CommitReceipt, CommitRequest};
25use kcode_dev_tools::{
26    ATTACH_OBJECT_WEB_LIB_TOOL, CALL_RUST_BIN_TOOL, RUST_BIN_TOOLS, RUST_LIB_TOOLS, WEB_LIB_TOOLS,
27    proposed_write_snapshot,
28};
29use kcode_dev_tools_chatend::{
30    FreeformWrite, SourceSnapshot, apply_snapshot, decode_freeform_write, prepare_freeform_write,
31};
32use kcode_history_ingress_context::{
33    Outcome as HistoryIngressContextOutcome, RecoveryOutcome as ContextRecoveryOutcome,
34};
35use kcode_kennedy_kweb_loader::{load_durable_batch, node_from_value};
36use kcode_kennedy_kweb_plan::{
37    Mutation as KwebMutation, Plan as KwebPlan, referenced_pending_nodes,
38};
39use kcode_kennedy_session_ingress::{is_terminal_external_response, restore_pending_turn};
40use kcode_kennedy_session_presentation::{RenderRequest, render};
41use kcode_kennedy_session_tool_contracts::{
42    DecodedTool, ManagedObjectArguments, ValidationRequest, decode, decode_managed_objects,
43    decode_note_to_self, validate,
44};
45use kcode_kennedy_session_tool_presentation::invocation_box_content;
46use kcode_kennedy_subagent_context::Context as SubagentContext;
47use kcode_kennedy_turn_admission::{
48    AdmissionKind, stage_turn_admission, validate_authoritative_user_event,
49};
50use kcode_kweb_context::{Context as KwebContext, Node as KwebNode};
51use kcode_kweb_db::NodeId;
52use kcode_server_object_envelopes::encode_file;
53use kcode_session_history::{
54    ErrorKind as HistoryErrorKind, LaunchSession as HistoryLaunchSession, NewSession,
55    Session as HistorySession,
56    chatend::{
57        BoxContent, BoxId, BoxOwner, CacheExpectation, ContextProjection, Event, EventId,
58        EventKind, PreparedProviderResume, ProviderContext, ProviderToolDefinition, SessionKind,
59        SessionMetadata,
60    },
61};
62use kcode_session_runtime_budget::{RoundBudget, RuntimeBudget, TimeBudget, TimeBudgetKind};
63use kcode_speaker_system::KTOOLS as SPEECH_CLASSIFICATION_TOOLS;
64use serde::{Deserialize, Serialize};
65use serde_json::{Value, json};
66use sha2::{Digest, Sha256};
67use uuid::Uuid;
68
69const BROWSER_CONVERSATION_REQUEST_TIMEOUT: Duration = Duration::from_secs(225 * 60);
70const HISTORY_INGRESS_REQUEST_TIMEOUT: Duration = Duration::from_secs(225 * 60);
71const HISTORY_INGRESS_ATTEMPT_DURATION: Duration = Duration::from_secs(45 * 60);
72const WAKEUP_REQUEST_TIMEOUT: Duration = Duration::from_secs(225 * 60);
73const SELF_TIME_HARD_STOP_ALLOWANCE: Duration = Duration::from_secs(15 * 60);
74const MAX_MEDIA_ENRICHMENT_BYTES: u64 = 20 * 1024 * 1024;
75const MAX_LAUNCH_INTENTS_PER_USER_TURN: usize = 10;
76const LAUNCH_SESSION_TOOL: &str = "LaunchSession";
77const KWEB_TOOL_INSTANCE: &str = "kweb";
78const TASK_BOARD_TOOLS: [&str; 8] = [
79    "CreateTaskCategory",
80    "GetTaskCategory",
81    "RemoveTaskCategory",
82    "CreateTask",
83    "GetTask",
84    "UpdateTask",
85    "RemoveTask",
86    "GetTopTaskOrphan",
87];
88const CONTEXT_OVERFLOW_WARNING_BOX_NAME: &str = "Context overflow warning";
89const CONTEXT_OVERFLOW_WARNING: &str = "Context size was exceeded, some context has been dehydrated. The session is now at risk of destabilizing, please perform any cleanup tasks and end the session";
90const INGRESS_FORCE_COMMIT_NOTE: &str = "ingress_force_commit";
91const PENDING_TURN_ADMISSION_KIND: &str = "pendingTurnAdmissionKind";
92const PENDING_TURN_ADMISSION_USER: &str = "user";
93const PENDING_TURN_ADMISSION_SOURCE: &str = "source";
94const CHECKPOINT_STATE_VERSION: u64 = 5;
95
96#[derive(Debug)]
97struct IngressTimeExpired;
98
99impl std::fmt::Display for IngressTimeExpired {
100    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        formatter.write_str("history ingress time expired before EndSession")
102    }
103}
104
105impl std::error::Error for IngressTimeExpired {}
106
107pub fn is_ingress_time_expired(error: &anyhow::Error) -> bool {
108    error.is::<IngressTimeExpired>()
109}
110
111fn ingress_time_remaining_at(deadline: &mut Option<Instant>, now: Instant) -> anyhow::Result<u64> {
112    let Some(current) = *deadline else {
113        *deadline = Some(
114            now.checked_add(HISTORY_INGRESS_ATTEMPT_DURATION)
115                .context("history ingress deadline overflow")?,
116        );
117        return Ok(HISTORY_INGRESS_ATTEMPT_DURATION.as_secs());
118    };
119    if now >= current {
120        return Err(anyhow::Error::new(IngressTimeExpired));
121    }
122    Ok(current.duration_since(now).as_secs())
123}
124
125#[derive(Clone, Debug)]
126pub struct RuntimeModel {
127    pub model: String,
128    pub reasoning_effort: String,
129    pub context_window_tokens: u64,
130}
131
132impl RuntimeModel {
133    pub fn from_intelligence(runtime: kcode_intelligence_router::RuntimeModel) -> Self {
134        Self {
135            model: runtime.model,
136            reasoning_effort: runtime.reasoning_effort,
137            context_window_tokens: runtime.context_window_tokens,
138        }
139    }
140
141    fn attribution(&self) -> String {
142        format!("{}-{}", self.model, self.reasoning_effort)
143    }
144}
145
146#[derive(Clone, Debug, PartialEq, Eq)]
147pub enum AgentMode {
148    Conversation,
149    FreeTime,
150    Wakeup,
151    Ingress { record_id: Option<String> },
152}
153
154#[derive(Clone, Copy, Debug, Eq, PartialEq)]
155pub enum TurnDeadlineKind {
156    Telegram,
157    SelfTimeHardStop,
158}
159
160#[derive(Clone, Copy, Debug, Eq, PartialEq)]
161pub struct TurnDeadline {
162    pub kind: TurnDeadlineKind,
163    pub at: DateTime<Utc>,
164}
165
166#[derive(Clone, Debug)]
167pub struct SessionOptions {
168    pub session_type: String,
169    pub root_node_ids: Vec<String>,
170    pub reference_root_node_ids: Vec<String>,
171    pub channel: Value,
172    pub free_time: Value,
173    pub orchestration: Value,
174    pub provenance_id: Option<String>,
175    pub mode: AgentMode,
176    pub source_session_type: Option<String>,
177    pub group_context: Value,
178    pub rust_lib_session_id: Option<String>,
179}
180
181impl SessionOptions {
182    pub fn conversation(session_type: impl Into<String>, roots: Vec<String>) -> Self {
183        Self {
184            session_type: session_type.into(),
185            root_node_ids: roots,
186            reference_root_node_ids: Vec::new(),
187            channel: Value::Null,
188            free_time: Value::Null,
189            orchestration: json!({"owner":"backend","status":"idle"}),
190            provenance_id: None,
191            mode: AgentMode::Conversation,
192            source_session_type: None,
193            group_context: Value::Null,
194            rust_lib_session_id: None,
195        }
196    }
197}
198
199fn restore_session_type(options: &mut SessionOptions, state: &Value) {
200    if !matches!(&options.mode, AgentMode::Ingress { .. }) {
201        options.session_type = state
202            .get("sessionType")
203            .and_then(Value::as_str)
204            .unwrap_or(&options.session_type)
205            .to_owned();
206    }
207}
208
209fn restore_commit_receipt(restored: Option<&Value>) -> anyhow::Result<Option<CommitReceipt>> {
210    restored
211        .and_then(|state| state.get("commitReceipt"))
212        .filter(|receipt| !receipt.is_null())
213        .cloned()
214        .map(serde_json::from_value)
215        .transpose()
216        .context("decoding the stored session commit receipt")
217}
218
219fn journal_kweb_plan(journal: &HistorySession) -> Option<&Value> {
220    journal
221        .state()
222        .current_ingress_attempt_events()
223        .iter()
224        .rev()
225        .find_map(|event| {
226            let EventKind::KwebPlanChanged { operation } = &event.kind else {
227                return None;
228            };
229            operation.get("plan")
230        })
231}
232
233#[derive(Clone, Debug, Deserialize)]
234#[serde(rename_all = "camelCase", deny_unknown_fields)]
235struct LaunchSessionArguments {
236    directive: String,
237    context_node_ids: Vec<String>,
238}
239
240#[derive(Clone, Debug, Deserialize, Serialize)]
241#[serde(rename_all = "camelCase")]
242struct LaunchIntent {
243    invocation_id: String,
244    user_turn_id: EventId,
245    started_at: String,
246    parent_session_id: String,
247    effective_context_tokens: u64,
248    root_node_ids: Vec<String>,
249    reference_root_node_ids: Vec<String>,
250    context_node_ids: Vec<String>,
251}
252
253#[derive(Serialize)]
254#[serde(rename_all = "camelCase")]
255struct LaunchSuccess<'a> {
256    session_id: &'a str,
257    command_id: &'a str,
258}
259
260fn decode_launch_session_arguments(value: &Value) -> anyhow::Result<LaunchSessionArguments> {
261    let arguments: LaunchSessionArguments =
262        serde_json::from_value(value.clone()).context("LaunchSession arguments are invalid")?;
263    anyhow::ensure!(
264        !arguments.directive.trim().is_empty(),
265        "LaunchSession directive must not be blank"
266    );
267    validate_canonical_distinct_ids(&arguments.context_node_ids, "context node")?;
268    Ok(arguments)
269}
270
271fn validate_canonical_distinct_ids(ids: &[String], label: &str) -> anyhow::Result<()> {
272    let mut seen = BTreeSet::new();
273    for id in ids {
274        canonical_id(id).with_context(|| format!("LaunchSession {label} ID is invalid"))?;
275        anyhow::ensure!(
276            seen.insert(id.as_str()),
277            "LaunchSession {label} ID {id} is duplicated"
278        );
279    }
280    Ok(())
281}
282
283fn validate_loaded_launch_context(
284    context: &KwebContext,
285    context_node_ids: &[String],
286) -> anyhow::Result<()> {
287    for id in context_node_ids {
288        anyhow::ensure!(
289            context.contains_full_node(id),
290            "LaunchSession context node {id} is not fully loaded in the parent context"
291        );
292    }
293    Ok(())
294}
295
296fn authoritative_user_box_event(journal: &HistorySession, event: &Event) -> Option<EventId> {
297    let EventKind::BoxCreated {
298        box_id,
299        owner: BoxOwner::User,
300        ..
301    } = &event.kind
302    else {
303        return None;
304    };
305    if *box_id != BoxId(event.id.0) {
306        return None;
307    }
308    journal
309        .state()
310        .box_state(*box_id)
311        .filter(|state| matches!(state.owner, BoxOwner::User))
312        .map(|_| event.id)
313}
314
315fn unique_user_box_event(
316    journal: &HistorySession,
317    events: &[Event],
318) -> anyhow::Result<Option<EventId>> {
319    let ids = events
320        .iter()
321        .filter_map(|event| authoritative_user_box_event(journal, event))
322        .collect::<Vec<_>>();
323    anyhow::ensure!(
324        ids.len() <= 1,
325        "multiple authoritative user inputs appeared in one launch-authority interval"
326    );
327    Ok(ids.into_iter().next())
328}
329
330#[derive(Clone)]
331struct RecoveredUserTurn {
332    id: EventId,
333    external_event_id: Option<String>,
334}
335
336enum RecoveredAdmission {
337    Unmarked(RecoveredUserTurn),
338    MarkedUser(RecoveredUserTurn),
339    MarkedSource,
340}
341
342fn classify_recovered_admission(
343    journal: &HistorySession,
344    event: &Event,
345) -> anyhow::Result<Option<RecoveredAdmission>> {
346    let Some(id) = authoritative_user_box_event(journal, event) else {
347        return Ok(None);
348    };
349    let Some(state) = journal.state().box_state(BoxId(id.0)) else {
350        return Ok(None);
351    };
352    let metadata = &state.canonical.content.metadata;
353    let record = RecoveredUserTurn {
354        id,
355        external_event_id: metadata
356            .get("externalEventId")
357            .and_then(Value::as_str)
358            .map(str::to_owned),
359    };
360    match metadata.get(PENDING_TURN_ADMISSION_KIND) {
361        None => Ok(Some(RecoveredAdmission::Unmarked(record))),
362        Some(Value::String(kind)) if kind == PENDING_TURN_ADMISSION_USER => {
363            validate_authoritative_user_event(journal, event.id)?;
364            Ok(Some(RecoveredAdmission::MarkedUser(record)))
365        }
366        Some(Value::String(kind)) if kind == PENDING_TURN_ADMISSION_SOURCE => {
367            Ok(Some(RecoveredAdmission::MarkedSource))
368        }
369        Some(_) => anyhow::bail!("pending turn admission marker is invalid"),
370    }
371}
372
373fn recovered_user_turns(
374    journal: &HistorySession,
375    events: &[Event],
376) -> anyhow::Result<Vec<RecoveredAdmission>> {
377    let mut found_unmarked = false;
378    let mut recovered = Vec::new();
379    for event in events {
380        match classify_recovered_admission(journal, event)? {
381            Some(RecoveredAdmission::Unmarked(record)) => {
382                anyhow::ensure!(
383                    !found_unmarked,
384                    "multiple unmarked authoritative user inputs appeared in one launch-authority interval"
385                );
386                found_unmarked = true;
387                recovered.push(RecoveredAdmission::Unmarked(record));
388            }
389            Some(admission) => recovered.push(admission),
390            None => {}
391        }
392    }
393    Ok(recovered)
394}
395
396fn validate_user_turn_id(journal: &HistorySession, id: EventId) -> anyhow::Result<()> {
397    let event = journal
398        .state()
399        .event(id)
400        .context("restored launch user-turn event does not exist")?;
401    anyhow::ensure!(
402        matches!(
403            classify_recovered_admission(journal, event)?,
404            Some(RecoveredAdmission::Unmarked(_) | RecoveredAdmission::MarkedUser(_))
405        ),
406        "restored launch user-turn event is not an authoritative user BoxCreated event"
407    );
408    Ok(())
409}
410
411fn consume_launch_bootstrap_marker(
412    orchestration: &mut Value,
413    launch_bootstrap_pending: &mut bool,
414) -> bool {
415    if !*launch_bootstrap_pending {
416        return false;
417    }
418    *launch_bootstrap_pending = false;
419    if !orchestration.is_object() {
420        *orchestration = json!({});
421    }
422    orchestration["launchBootstrapPending"] = json!(false);
423    true
424}
425
426fn grant_marked_user_launch_authority(
427    id: EventId,
428    orchestration: &mut Value,
429    launch_bootstrap_pending: &mut bool,
430    launch_user_turn_id: &mut Option<EventId>,
431) {
432    consume_launch_bootstrap_marker(orchestration, launch_bootstrap_pending);
433    *launch_user_turn_id = Some(id);
434}
435
436fn user_turn_launch_authority(
437    user_turn: Option<EventId>,
438    orchestration: &mut Value,
439    launch_bootstrap_pending: &mut bool,
440) -> Option<EventId> {
441    if consume_launch_bootstrap_marker(orchestration, launch_bootstrap_pending) {
442        None
443    } else {
444        user_turn
445    }
446}
447
448fn reconcile_recovered_launch_authority(
449    pending_turn: bool,
450    recovered_user_turn: Option<EventId>,
451    launch_provenance: &Value,
452    orchestration: &mut Value,
453    launch_bootstrap_pending: &mut bool,
454    launch_user_turn_id: &mut Option<EventId>,
455) {
456    if let Some(recovered) = recovered_user_turn {
457        if *launch_bootstrap_pending || !launch_provenance.is_null() {
458            consume_launch_bootstrap_marker(orchestration, launch_bootstrap_pending);
459            *launch_user_turn_id = None;
460        } else {
461            *launch_user_turn_id = Some(recovered);
462        }
463    }
464    if !pending_turn || *launch_bootstrap_pending {
465        *launch_user_turn_id = None;
466    }
467}
468
469struct RecoveredAdmissionReplayState<'a> {
470    pending_turn: &'a bool,
471    launch_provenance: &'a Value,
472    orchestration: &'a mut Value,
473    launch_bootstrap_pending: &'a mut bool,
474    launch_user_turn_id: &'a mut Option<EventId>,
475    rounds_used: &'a mut u64,
476    pending_external_event_id: &'a mut Option<String>,
477}
478
479fn replay_recovered_admissions(
480    recovered: Vec<RecoveredAdmission>,
481    state: RecoveredAdmissionReplayState<'_>,
482) {
483    for admission in recovered {
484        match admission {
485            RecoveredAdmission::Unmarked(record) => {
486                reconcile_recovered_launch_authority(
487                    *state.pending_turn,
488                    Some(record.id),
489                    state.launch_provenance,
490                    state.orchestration,
491                    state.launch_bootstrap_pending,
492                    state.launch_user_turn_id,
493                );
494                *state.rounds_used = 0;
495                *state.pending_external_event_id = record.external_event_id;
496            }
497            RecoveredAdmission::MarkedUser(record) => {
498                grant_marked_user_launch_authority(
499                    record.id,
500                    state.orchestration,
501                    state.launch_bootstrap_pending,
502                    state.launch_user_turn_id,
503                );
504                *state.rounds_used = 0;
505                *state.pending_external_event_id = record.external_event_id;
506            }
507            RecoveredAdmission::MarkedSource => {}
508        }
509    }
510}
511
512fn completed_invocation_ids(journal: &HistorySession) -> BTreeSet<String> {
513    journal
514        .state()
515        .events
516        .iter()
517        .filter_map(|event| {
518            let EventKind::ToolCompleted {
519                invocation_id: Some(id),
520                ..
521            } = &event.kind
522            else {
523                return None;
524            };
525            Some(id.clone())
526        })
527        .collect()
528}
529
530fn pruned_launch_intents(
531    intents: &[LaunchIntent],
532    current_turn: Option<EventId>,
533    completed: &BTreeSet<String>,
534) -> Vec<LaunchIntent> {
535    intents
536        .iter()
537        .filter(|intent| {
538            Some(intent.user_turn_id) == current_turn || !completed.contains(&intent.invocation_id)
539        })
540        .cloned()
541        .collect()
542}
543
544fn invocation_arguments<'a>(
545    journal: &'a HistorySession,
546    invocation_id: &str,
547) -> anyhow::Result<&'a Value> {
548    journal
549        .state()
550        .events
551        .iter()
552        .find_map(|event| {
553            let EventKind::ToolInvoked {
554                tool_name,
555                arguments,
556                invocation_id: Some(id),
557                ..
558            } = &event.kind
559            else {
560                return None;
561            };
562            (tool_name == LAUNCH_SESSION_TOOL && id == invocation_id).then_some(arguments)
563        })
564        .with_context(|| {
565            format!(
566                "launch intent {} has no matching ToolInvoked event",
567                invocation_id
568            )
569        })
570}
571
572fn launch_success_json(session_id: &str, command_id: &str) -> anyhow::Result<String> {
573    serde_json::to_string(&LaunchSuccess {
574        session_id,
575        command_id,
576    })
577    .context("serializing LaunchSession result")
578}
579
580fn tool_invocation_content(name: &str, arguments: &Value) -> anyhow::Result<BoxContent> {
581    if name == LAUNCH_SESSION_TOOL {
582        return Ok(BoxContent::text("LaunchSession"));
583    }
584    invocation_box_content(name, arguments)
585}
586
587pub struct Session {
588    api: Service,
589    subagent_codex_prompt: String,
590    runtime: RuntimeModel,
591    journal: HistorySession,
592    plan: KwebPlan,
593    pub session_type: String,
594    pub channel: Value,
595    pub free_time: Value,
596    pub orchestration: Value,
597    pub provenance_id: Option<String>,
598    pub rust_lib_session_id: String,
599    pub root_node_ids: Vec<String>,
600    pub reference_root_node_ids: Vec<String>,
601    pub started_at: String,
602    pub transcript: Vec<Value>,
603    pub pending_turn: bool,
604    pub pending_external_event_id: Option<String>,
605    pub completed: bool,
606    pub rounds_used: u64,
607    commit_receipt: Option<CommitReceipt>,
608    commit_author: String,
609    mode: AgentMode,
610    source_session_type: Option<String>,
611    group_context: Value,
612    context: KwebContext,
613    free_time_end_reason: Option<String>,
614    fatal_persistence_error: Option<String>,
615    active_provider_deadline: Option<DateTime<Utc>>,
616    active_turn_deadline: Option<TurnDeadline>,
617    provider_affinity: Option<ProviderAffinityState>,
618    next_thread_reset_reason: Option<String>,
619    ingress_deadline: Option<Instant>,
620    previous_ingress_attempt_timed_out: bool,
621    launch_provenance: Value,
622    launch_context_node_ids: Vec<String>,
623    launch_user_turn_id: Option<EventId>,
624    launch_intents: Vec<LaunchIntent>,
625    launch_bootstrap_pending: bool,
626    turn_lease_slot: Option<Weak<TurnLeaseToken>>,
627}
628
629struct TurnLeaseToken;
630
631struct TurnLease {
632    token: Arc<TurnLeaseToken>,
633}
634
635impl TurnLease {
636    fn acquire(slot: &mut Option<Weak<TurnLeaseToken>>) -> anyhow::Result<Self> {
637        anyhow::ensure!(
638            slot.as_ref().and_then(Weak::upgrade).is_none(),
639            "a stepped session turn is already active"
640        );
641        let token = Arc::new(TurnLeaseToken);
642        *slot = Some(Arc::downgrade(&token));
643        Ok(Self { token })
644    }
645
646    fn validate(&self, slot: &Option<Weak<TurnLeaseToken>>) -> anyhow::Result<()> {
647        anyhow::ensure!(
648            slot.as_ref()
649                .and_then(Weak::upgrade)
650                .is_some_and(|active| Arc::ptr_eq(&self.token, &active)),
651            "the stepped session turn is stale"
652        );
653        Ok(())
654    }
655}
656
657struct PrimaryTurnState {
658    accounting: Option<kcode_intelligence_chatend::TopLevelCall>,
659    pending_freeform_write: Option<PendingFreeformWrite>,
660    deadline_after_response: bool,
661    operation_id: Uuid,
662    prepared_cache: Option<PreparedCacheObservation>,
663    provider_synchronized_after: Option<EventId>,
664    restart_fresh_reason: Option<String>,
665    exact_tool_result: bool,
666    used_tool: bool,
667    finish_requested: bool,
668    emitted_response: bool,
669    pending_capture: Option<Value>,
670}
671
672#[must_use]
673pub struct SessionTurn {
674    lease: TurnLease,
675    user_id: String,
676    completed_rounds: u64,
677    round_limit: u64,
678    state: PrimaryTurnState,
679    at_yielded_boundary: bool,
680    admission_poisoned: bool,
681}
682
683enum PendingInferenceAction {
684    Start {
685        runtime: kcode_agent_runtime::AgentRuntime,
686        request: kcode_agent_runtime::SessionInferenceRequest,
687    },
688    Next {
689        inference: kcode_agent_runtime::SessionInference,
690    },
691    Respond {
692        inference: kcode_agent_runtime::SessionInference,
693        call_id: String,
694        result: kcode_codex_runtime_v2::ToolResult,
695        stop: bool,
696    },
697}
698
699#[must_use]
700pub struct PendingSessionInference {
701    turn: SessionTurn,
702    action: Box<PendingInferenceAction>,
703}
704
705enum SessionInferenceWakeKind {
706    Event {
707        inference: kcode_agent_runtime::SessionInference,
708        event: anyhow::Result<Option<kcode_agent_runtime::SessionInferenceEvent>>,
709    },
710    StartFailed(anyhow::Error),
711    RespondFailed {
712        inference: kcode_agent_runtime::SessionInference,
713        error: anyhow::Error,
714    },
715    RespondedStop {
716        inference: kcode_agent_runtime::SessionInference,
717    },
718}
719
720#[must_use]
721pub struct SessionInferenceWake {
722    turn: SessionTurn,
723    kind: SessionInferenceWakeKind,
724}
725
726#[must_use]
727pub enum TurnBoundary {
728    Await(PendingSessionInference),
729    Yield(SessionTurn),
730    Complete(Option<String>),
731}
732
733impl PendingSessionInference {
734    pub async fn wait(self) -> SessionInferenceWake {
735        let Self { turn, action } = self;
736        let kind = match *action {
737            PendingInferenceAction::Start { runtime, request } => {
738                match runtime.start_session_inference(request).await {
739                    Ok(mut inference) => {
740                        let event = inference.next_event().await;
741                        SessionInferenceWakeKind::Event { inference, event }
742                    }
743                    Err(error) => SessionInferenceWakeKind::StartFailed(error),
744                }
745            }
746            PendingInferenceAction::Next { mut inference } => {
747                let event = inference.next_event().await;
748                SessionInferenceWakeKind::Event { inference, event }
749            }
750            PendingInferenceAction::Respond {
751                mut inference,
752                call_id,
753                result,
754                stop,
755            } => match inference.respond(&call_id, result).await {
756                Ok(()) if stop => SessionInferenceWakeKind::RespondedStop { inference },
757                Ok(()) => {
758                    let event = inference.next_event().await;
759                    SessionInferenceWakeKind::Event { inference, event }
760                }
761                Err(error) => SessionInferenceWakeKind::RespondFailed { inference, error },
762            },
763        };
764        SessionInferenceWake { turn, kind }
765    }
766}
767
768#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
769#[serde(rename_all = "camelCase")]
770struct ProviderAffinityState {
771    continuation: kcode_intelligence_router::AgentContinuation,
772    synchronized_event_id: EventId,
773    material_fingerprint: String,
774}
775
776#[derive(Debug, Eq, PartialEq)]
777enum NativeProviderResumePreparation {
778    Continue { marker_lines: Vec<String> },
779    RestartFresh { reason: String },
780}
781
782fn apply_prepared_provider_resume(
783    provider_affinity: &mut Option<ProviderAffinityState>,
784    next_thread_reset_reason: &mut Option<String>,
785    prepared: PreparedProviderResume,
786) -> NativeProviderResumePreparation {
787    match prepared.thread_reset_reason {
788        Some(reason) => {
789            *provider_affinity = None;
790            *next_thread_reset_reason = Some(reason.clone());
791            NativeProviderResumePreparation::RestartFresh { reason }
792        }
793        None => NativeProviderResumePreparation::Continue {
794            marker_lines: prepared.marker_lines,
795        },
796    }
797}
798
799fn restore_provider_affinity(
800    restored: Option<&Value>,
801    fresh_ingress_attempt: bool,
802) -> anyhow::Result<Option<ProviderAffinityState>> {
803    let state_version = restored
804        .and_then(|state| state.get("stateVersion"))
805        .and_then(Value::as_u64);
806    if fresh_ingress_attempt || state_version != Some(CHECKPOINT_STATE_VERSION) {
807        return Ok(None);
808    }
809    restored
810        .and_then(|state| state.get("providerAffinity"))
811        .filter(|value| !value.is_null())
812        .cloned()
813        .map(serde_json::from_value)
814        .transpose()
815        .context("restored provider affinity is invalid")
816}
817
818#[derive(Clone, Copy, Debug, Eq, PartialEq)]
819enum InputStage {
820    Accepted,
821}
822
823#[derive(Clone, Copy, Debug, Eq, PartialEq)]
824enum ContextRecovery {
825    NotNeeded,
826    Recovered,
827    Irreducible,
828}
829
830fn kweb_slot_box_ids(journal: &HistorySession) -> Vec<BoxId> {
831    journal
832        .state()
833        .tools
834        .get(KWEB_TOOL_INSTANCE)
835        .map(|tool| tool.slots.iter().map(|slot| slot.box_id).collect())
836        .unwrap_or_default()
837}
838
839fn load_box_changes(before: &[BoxId], after: &[BoxId], stale: &[BoxId]) -> Vec<BoxId> {
840    let before = before.iter().copied().collect::<BTreeSet<_>>();
841    let stale = stale.iter().copied().collect::<BTreeSet<_>>();
842    let mut emitted = BTreeSet::new();
843    after
844        .iter()
845        .copied()
846        .filter(|id| (!before.contains(id) || stale.contains(id)) && emitted.insert(*id))
847        .collect()
848}
849
850fn render_load_nodes_result(
851    journal: &HistorySession,
852    changed_box_ids: &[BoxId],
853    footer_lines: &[String],
854) -> anyhow::Result<String> {
855    let changed_box_ids = changed_box_ids
856        .iter()
857        .map(ToString::to_string)
858        .collect::<Vec<_>>();
859    let projected_boxes = journal
860        .state()
861        .projection_with_footer_lines(footer_lines)
862        .items
863        .into_iter()
864        .filter(|item| !item.marker)
865        .map(|item| (item.box_id.to_string(), item.text))
866        .collect::<Vec<_>>();
867    render(RenderRequest::LoadNodes {
868        changed_box_ids: &changed_box_ids,
869        projected_boxes: &projected_boxes,
870    })
871}
872
873fn provider_tool_result_with_context_footer(footer: &str, result: &str) -> String {
874    render(RenderRequest::ProviderFooter { result, footer })
875        .expect("provider-footer rendering is infallible")
876}
877
878fn completes_before_provider_resume(outcome: &kcode_agent_runtime::SessionToolOutcome) -> bool {
879    outcome.stop || (outcome.ok && outcome.finish_after_round)
880}
881
882fn append_slow_tool_duration(text: &mut String, elapsed: Duration) {
883    *text = render(RenderRequest::SlowTool { text, elapsed })
884        .expect("slow-tool rendering is infallible");
885}
886
887fn log_primary_thread_observation(
888    operation_id: Uuid,
889    round: u64,
890    requested_model: &str,
891    prepared: &PreparedCacheObservation,
892    provider_thread_id: Option<&str>,
893    input_tokens: u64,
894    cached_input_tokens: u64,
895) {
896    tracing::info!(
897        affinity_scope = "primary",
898        %operation_id,
899        round,
900        provider = prepared.provider,
901        requested_model,
902        model = prepared.model,
903        thread_action = prepared.thread_action,
904        provider_thread_id = provider_thread_id.unwrap_or(""),
905        thread_reset_reason = prepared.thread_reset_reason.as_deref().unwrap_or(""),
906        projection_hash = prepared.projection_hash,
907        provider_input_hash = prepared.provider_input_hash,
908        provider_input_bytes = prepared.provider_input_bytes,
909        input_tokens,
910        cached_input_tokens,
911        "Provider thread-affinity observation"
912    );
913}
914
915fn render_web_search_result(
916    result: &kcode_intelligence_router::SearchResponse,
917) -> anyhow::Result<String> {
918    let sources = result
919        .sources
920        .iter()
921        .map(|source| (source.title.clone(), source.url.clone()))
922        .collect::<Vec<_>>();
923    render(RenderRequest::WebSearch {
924        answer: &result.answer,
925        sources: &sources,
926    })
927}
928
929fn render_web_fetch_result(
930    result: &kcode_intelligence_router::FetchResponse,
931) -> anyhow::Result<String> {
932    render(RenderRequest::WebFetch {
933        url: &result.url,
934        title: result.title.as_deref(),
935        content_type: &result.content_type,
936        truncated: result.truncated,
937        content: &result.content,
938    })
939}
940
941fn render_media_annotation_result(
942    object_id: &str,
943    file_name: &str,
944    content_type: &str,
945    result: &kcode_intelligence_router::AnnotationResponse,
946) -> anyhow::Result<String> {
947    render(RenderRequest::MediaAnnotation {
948        object_id,
949        file_name,
950        content_type,
951        model: &result.model,
952        complete: result.complete,
953        incomplete_reason: result.incomplete_reason.as_deref(),
954        text: &result.text,
955    })
956}
957
958fn render_audio_transcription_result(
959    object_id: &str,
960    file_name: &str,
961    content_type: &str,
962    result: &kcode_intelligence_router::TranscriptionResponse,
963) -> anyhow::Result<String> {
964    render(RenderRequest::AudioTranscription {
965        object_id,
966        file_name,
967        content_type,
968        model: &result.model,
969        text: &result.text,
970    })
971}
972
973fn render_document_extraction_result(
974    object_id: &str,
975    file_name: &str,
976    result: &kcode_intelligence_router::DocumentExtraction,
977) -> anyhow::Result<String> {
978    render(RenderRequest::DocumentExtraction {
979        object_id,
980        file_name,
981        format: &result.format,
982        characters: result.characters,
983        truncated: result.truncated,
984        text: &result.text,
985    })
986}
987
988struct ToolCall {
989    name: String,
990    arguments: Value,
991}
992
993#[derive(Deserialize)]
994#[serde(rename_all = "camelCase", deny_unknown_fields)]
995struct TaskId {
996    task_id: String,
997}
998
999#[derive(Deserialize)]
1000#[serde(rename_all = "camelCase", deny_unknown_fields)]
1001struct CategoryId {
1002    category_id: String,
1003}
1004
1005#[derive(Deserialize)]
1006#[serde(rename_all = "camelCase", deny_unknown_fields)]
1007struct CategoryCall {
1008    category_id: String,
1009    #[serde(default)]
1010    offset: u64,
1011    #[serde(default = "task_page_limit")]
1012    limit: u32,
1013}
1014
1015#[derive(Deserialize)]
1016#[serde(deny_unknown_fields)]
1017struct EmptyCall {}
1018
1019fn task_page_limit() -> u32 {
1020    50
1021}
1022
1023struct RecordedToolInvocation {
1024    invocation_id: String,
1025    tool_instance: String,
1026    tool_name: String,
1027}
1028
1029fn record_tool_completion_event(
1030    journal: &mut HistorySession,
1031    invocation: Option<&RecordedToolInvocation>,
1032    outcome: Value,
1033) -> anyhow::Result<EventId> {
1034    let (tool_instance, tool_name, invocation_id) = invocation
1035        .map(|invocation| {
1036            (
1037                invocation.tool_instance.clone(),
1038                invocation.tool_name.clone(),
1039                Some(invocation.invocation_id.clone()),
1040            )
1041        })
1042        .unwrap_or_else(|| ("call_ktool".into(), "call_ktool".into(), None));
1043    journal.record(
1044        now(),
1045        EventKind::ToolCompleted {
1046            tool_instance,
1047            tool_name,
1048            outcome,
1049            invocation_id,
1050        },
1051    )
1052}
1053
1054fn ensure_tool_result_box(
1055    journal: &mut HistorySession,
1056    invocation: Option<&RecordedToolInvocation>,
1057    text: &str,
1058    ok: bool,
1059) -> anyhow::Result<String> {
1060    let Some(invocation) = invocation else {
1061        journal.create_box(
1062            now(),
1063            "Kennedy tool result",
1064            BoxOwner::Controller,
1065            BoxContent::text(text),
1066        )?;
1067        return Ok(text.to_owned());
1068    };
1069    let matches = journal
1070        .state()
1071        .boxes
1072        .values()
1073        .filter(|state| {
1074            matches!(state.owner, BoxOwner::Controller)
1075                && state
1076                    .canonical
1077                    .content
1078                    .metadata
1079                    .get("toolInvocationId")
1080                    .and_then(Value::as_str)
1081                    == Some(invocation.invocation_id.as_str())
1082        })
1083        .map(|state| {
1084            (
1085                state.id,
1086                state.canonical.content.text.clone(),
1087                state
1088                    .canonical
1089                    .content
1090                    .metadata
1091                    .get("toolResultOk")
1092                    .and_then(Value::as_bool),
1093            )
1094        })
1095        .collect::<Vec<_>>();
1096    anyhow::ensure!(
1097        matches.len() <= 1,
1098        "tool invocation {} has duplicate durable result boxes",
1099        invocation.invocation_id
1100    );
1101    if let Some((_box_id, stored_text, stored_ok)) = matches.into_iter().next() {
1102        anyhow::ensure!(
1103            stored_ok == Some(ok),
1104            "tool invocation {} has a result box with a conflicting outcome",
1105            invocation.invocation_id
1106        );
1107        return Ok(stored_text);
1108    }
1109
1110    let mut content = BoxContent::text(text);
1111    content.metadata = json!({
1112        "toolInvocationId":invocation.invocation_id,
1113        "toolInstance":invocation.tool_instance,
1114        "toolName":invocation.tool_name,
1115        "toolResultOk":ok,
1116    });
1117    journal.create_box(now(), "Kennedy tool result", BoxOwner::Controller, content)?;
1118    Ok(text.to_owned())
1119}
1120
1121fn complete_launch_reconciliation(
1122    journal: &mut HistorySession,
1123    invocation: &RecordedToolInvocation,
1124    result: Result<kcode_session_history::SessionLaunch, kcode_session_history::Error>,
1125) -> anyhow::Result<()> {
1126    if completed_invocation_ids(journal).contains(&invocation.invocation_id) {
1127        return Ok(());
1128    }
1129    let (ok, text) = match result {
1130        Ok(launch) => (
1131            true,
1132            launch_success_json(&launch.session_id, &launch.command_id)?,
1133        ),
1134        Err(error)
1135            if matches!(
1136                error.kind,
1137                HistoryErrorKind::InvalidInput | HistoryErrorKind::Conflict
1138            ) =>
1139        {
1140            (false, format!("LaunchSession failed: {}", error.message))
1141        }
1142        Err(error) => {
1143            anyhow::bail!(
1144                "LaunchSession reconciliation remains unresolved ({}): {}",
1145                error.kind.code(),
1146                error.message
1147            );
1148        }
1149    };
1150    let text = ensure_tool_result_box(journal, Some(invocation), &text, ok)?;
1151    record_tool_completion_event(journal, Some(invocation), json!({"ok":ok,"result":text}))?;
1152    Ok(())
1153}
1154
1155struct PendingFreeformWrite {
1156    request: FreeformWrite,
1157    call_box_id: BoxId,
1158}
1159
1160struct ToolOutcome {
1161    text: String,
1162    store_result: bool,
1163    ok: bool,
1164    end_session: bool,
1165    freeform_write: Option<FreeformWrite>,
1166    managed_source_snapshot: Option<SourceSnapshot>,
1167    exact_result: bool,
1168}
1169
1170fn result_displays_snapshot(result: &str, snapshot: &SourceSnapshot) -> bool {
1171    result == snapshot.text
1172}
1173
1174fn subagent_managed_write_fits(
1175    context: &SubagentContext,
1176    call: &ToolCall,
1177    budget: &kcode_agent_runtime::ContextBudget,
1178) -> bool {
1179    let Some(snapshot) = proposed_write_snapshot(&call.name, &call.arguments) else {
1180        return true;
1181    };
1182    let state = context.source_state(&snapshot);
1183    budget.fits_state(state.key, state.text)
1184}
1185
1186struct KennedySubagentHost<'a> {
1187    session: &'a mut Session,
1188    context: SubagentContext,
1189    captures: HashMap<String, FreeformWrite>,
1190}
1191
1192struct KennedySessionHost<'a, C> {
1193    session: &'a mut Session,
1194    checkpoint: &'a mut C,
1195    state: &'a mut PrimaryTurnState,
1196}
1197
1198struct PreparedCacheObservation {
1199    cacheable_prefix_bytes: u64,
1200    expectation: CacheExpectation,
1201    material_fingerprint: String,
1202    projection_hash: String,
1203    logical_input: String,
1204    provider_input_hash: String,
1205    provider_input_bytes: u64,
1206    thread_action: String,
1207    thread_reset_reason: Option<String>,
1208    estimated_input_tokens: u64,
1209    raw_estimated_input_tokens: u64,
1210    provider: String,
1211    model: String,
1212}
1213
1214fn is_kweb_mutation(name: &str) -> bool {
1215    matches!(
1216        name,
1217        "ConnectNodes" | "ConsolidateFanout" | "SetFixedConnection" | "CreateNode" | "UpdateNode"
1218    )
1219}
1220
1221fn subagent_unavailable_reason(name: &str) -> Option<&'static str> {
1222    match name {
1223        LAUNCH_SESSION_TOOL => Some(
1224            "LaunchSession is unavailable inside a subagent. Only an authorized genuine parent user turn may launch a session.",
1225        ),
1226        "RunSubagent" => {
1227            Some("RunSubagent is unavailable inside a subagent. Only Kennedy may launch subagents.")
1228        }
1229        "EndSession" => Some(
1230            "EndSession is unavailable inside a subagent. A child cannot control the parent session lifecycle.",
1231        ),
1232        "DehydrateBoxes" | "SummarizeBox" | "HydrateBox" | "BoxesIntoObjects" => {
1233            Some("Parent box controls are unavailable inside a box-free subagent context.")
1234        }
1235        _ => None,
1236    }
1237}
1238
1239fn ensure_plan_node_known(plan: &KwebPlan, context: &KwebContext, id: &str) -> anyhow::Result<()> {
1240    if id.starts_with("pending:") {
1241        anyhow::ensure!(
1242            plan.contains_pending(id),
1243            "pending node {id} is not part of this session"
1244        );
1245    } else {
1246        canonical_id(id)?;
1247        anyhow::ensure!(
1248            context.contains_full_node(id),
1249            "node {id} is not loaded; call LoadNodes first"
1250        );
1251    }
1252    Ok(())
1253}
1254
1255fn kweb_mutation(
1256    decoded: DecodedTool,
1257    context: &KwebContext,
1258    plan: &KwebPlan,
1259    journal: &mut HistorySession,
1260) -> anyhow::Result<KwebMutation> {
1261    Ok(match decoded {
1262        DecodedTool::ConnectNodes(nodes) => KwebMutation::ConnectNodes(nodes),
1263        DecodedTool::ConsolidateFanout {
1264            parent,
1265            fanout,
1266            aggregator,
1267        } => KwebMutation::ConsolidateFanout {
1268            parent,
1269            fanout,
1270            aggregator,
1271        },
1272        DecodedTool::SetFixedConnection {
1273            parent,
1274            child,
1275            slot,
1276        } => KwebMutation::SetFixedConnection {
1277            parent,
1278            child,
1279            slot,
1280        },
1281        DecodedTool::CreateNode {
1282            parents,
1283            owner,
1284            short_name,
1285            short_description,
1286            long_description,
1287        } => {
1288            for id in parents.iter().chain(std::iter::once(&owner)) {
1289                if id != "self" && id != "unowned" {
1290                    ensure_plan_node_known(plan, context, id)?;
1291                }
1292            }
1293            KwebMutation::CreateNode {
1294                pending_id: journal.allocate_pending_node(now())?.to_string(),
1295                parents,
1296                owner,
1297                short_name,
1298                short_description,
1299                long_description,
1300            }
1301        }
1302        DecodedTool::UpdateNode {
1303            id,
1304            owner,
1305            short_name,
1306            short_description,
1307            long_description,
1308        } => KwebMutation::UpdateNode {
1309            id,
1310            owner,
1311            short_name,
1312            short_description,
1313            long_description,
1314        },
1315        _ => anyhow::bail!("decoded contract did not match a Kweb mutation"),
1316    })
1317}
1318
1319fn connect_nodes_result_with_counts(
1320    result: String,
1321    plan: &KwebPlan,
1322    ids: &[String],
1323) -> anyhow::Result<String> {
1324    let (updates, creates) = plan.context_projection();
1325    let mut seen = BTreeSet::new();
1326    let mut counts = Vec::new();
1327    for id in ids {
1328        if !seen.insert(id.as_str()) {
1329            continue;
1330        }
1331        let count = updates
1332            .get(id)
1333            .map(|node| node.recent_connections.len())
1334            .or_else(|| {
1335                creates
1336                    .iter()
1337                    .find(|create| create.pending_id == id.as_str())
1338                    .map(|create| create.data.recent_connections.len())
1339            })
1340            .with_context(|| format!("ConnectNodes did not stage touched node {id}"))?;
1341        counts.push(format!("{id}: {count}"));
1342    }
1343    Ok(format!(
1344        "{result}\nPost-call recent connection counts: {}.",
1345        counts.join(", ")
1346    ))
1347}
1348
1349fn execute_kweb_mutation(
1350    name: &str,
1351    decoded: DecodedTool,
1352    context: &KwebContext,
1353    plan: &mut KwebPlan,
1354    journal: &mut HistorySession,
1355) -> anyhow::Result<(String, Vec<String>)> {
1356    let mutation = kweb_mutation(decoded, context, plan, journal)
1357        .with_context(|| format!("decoded contract for {name} did not match its Kweb mutation"))?;
1358    let connect_nodes = match &mutation {
1359        KwebMutation::ConnectNodes(ids) => Some(ids.clone()),
1360        _ => None,
1361    };
1362    let referenced = referenced_pending_nodes(&mutation);
1363    let mut result = plan.apply(context, mutation)?;
1364    if let Some(ids) = connect_nodes {
1365        result = connect_nodes_result_with_counts(result, plan, &ids)?;
1366    }
1367    Ok((result, referenced))
1368}
1369
1370fn inference_error_receipt(
1371    error: &anyhow::Error,
1372) -> Option<kcode_intelligence_router::UsageReceipt> {
1373    error.chain().find_map(|cause| {
1374        cause
1375            .downcast_ref::<kcode_intelligence_router::Error>()
1376            .and_then(|error| error.receipt().cloned())
1377    })
1378}
1379
1380async fn record_unavailable_inference<C, F>(
1381    host: &mut KennedySessionHost<'_, C>,
1382    inference: &mut kcode_agent_runtime::SessionInference,
1383    round: u64,
1384) -> anyhow::Result<()>
1385where
1386    C: FnMut(Value) -> F + Send,
1387    F: Future<Output = anyhow::Result<()>> + Send,
1388{
1389    let receipt = inference.finish_unavailable()?;
1390    host.record(kcode_agent_runtime::SessionEvent::ProviderReceipt {
1391        round,
1392        usage: None,
1393        receipt,
1394        continuation: None,
1395    })
1396    .await
1397}
1398
1399impl Session {
1400    pub fn mark_previous_ingress_attempt_timed_out(&mut self) {
1401        if matches!(self.mode, AgentMode::Ingress { .. })
1402            && !self.previous_ingress_attempt_timed_out
1403        {
1404            self.invalidate_active_stepped_turn();
1405            self.previous_ingress_attempt_timed_out = true;
1406        }
1407    }
1408
1409    fn ingress_time_remaining(&mut self) -> anyhow::Result<Option<u64>> {
1410        if !matches!(self.mode, AgentMode::Ingress { .. }) {
1411            return Ok(None);
1412        }
1413        ingress_time_remaining_at(&mut self.ingress_deadline, Instant::now()).map(Some)
1414    }
1415
1416    fn runtime_budget(&self) -> RuntimeBudget {
1417        let Some(provider_deadline) = self.active_provider_deadline else {
1418            return RuntimeBudget::default();
1419        };
1420        let mut time_limits = vec![TimeBudget {
1421            kind: TimeBudgetKind::ProviderCall,
1422            remaining: remaining_until(provider_deadline),
1423        }];
1424        if matches!(self.mode, AgentMode::FreeTime)
1425            && let Some(work_deadline) = deadline(&self.free_time)
1426        {
1427            time_limits.push(TimeBudget {
1428                kind: TimeBudgetKind::SelfTimeWork,
1429                remaining: remaining_until(work_deadline),
1430            });
1431        }
1432        if let Some(outer) = self.active_turn_deadline {
1433            time_limits.push(TimeBudget {
1434                kind: match outer.kind {
1435                    TurnDeadlineKind::Telegram => TimeBudgetKind::TelegramTurn,
1436                    TurnDeadlineKind::SelfTimeHardStop => TimeBudgetKind::SelfTimeHardStop,
1437                },
1438                remaining: remaining_until(outer.at),
1439            });
1440        }
1441        RuntimeBudget {
1442            rounds: Some(RoundBudget {
1443                used: self.rounds_used,
1444                limit: kcode_agent_runtime::DEFAULT_ROUND_LIMIT,
1445            }),
1446            time_limits,
1447        }
1448    }
1449
1450    fn projection(&self) -> ContextProjection {
1451        self.journal
1452            .state()
1453            .projection_with_footer_lines(&self.runtime_budget().footer_lines())
1454    }
1455
1456    fn provider_material_fingerprint(&self, tool_description: &str) -> String {
1457        let material = json!({
1458            "model":self.runtime.model,
1459            "reasoningEffort":self.runtime.reasoning_effort,
1460            "tool":"call_ktool",
1461            "toolDescription":tool_description,
1462        });
1463        hex::encode(Sha256::digest(
1464            serde_json::to_vec(&material).expect("provider material always serializes"),
1465        ))
1466    }
1467
1468    fn begin_provider_call_budget(&mut self, timeout: Option<Duration>) {
1469        self.active_provider_deadline = timeout.and_then(|timeout| {
1470            chrono::Duration::from_std(timeout)
1471                .ok()
1472                .map(|timeout| Utc::now() + timeout)
1473        });
1474    }
1475
1476    fn clear_turn_deadlines(&mut self) {
1477        self.active_provider_deadline = None;
1478        self.active_turn_deadline = None;
1479    }
1480
1481    fn invalidate_active_stepped_turn(&mut self) {
1482        self.turn_lease_slot = None;
1483        self.clear_turn_deadlines();
1484    }
1485
1486    fn synchronize_provider_known_events(&mut self) {
1487        if let Some(affinity) = self.provider_affinity.as_mut()
1488            && let Some(event) = self.journal.state().events.last()
1489        {
1490            affinity.synchronized_event_id = event.id;
1491        }
1492    }
1493
1494    pub async fn new(
1495        api: Service,
1496        system_prompt: String,
1497        subagent_codex_prompt: String,
1498        runtime: RuntimeModel,
1499        started_at: String,
1500        mut options: SessionOptions,
1501        restored: Option<&Value>,
1502    ) -> anyhow::Result<Self> {
1503        if let Some(state) = restored {
1504            restore_session_type(&mut options, state);
1505            options.channel = state.get("channel").cloned().unwrap_or(options.channel);
1506            options.free_time = state.get("freeTime").cloned().unwrap_or(options.free_time);
1507            options.orchestration = state
1508                .get("orchestration")
1509                .cloned()
1510                .unwrap_or(options.orchestration);
1511        }
1512        if options.group_context.is_null() {
1513            options.group_context = options
1514                .channel
1515                .get("groupContext")
1516                .cloned()
1517                .unwrap_or(Value::Null);
1518        }
1519        options
1520            .reference_root_node_ids
1521            .retain(|id| !options.root_node_ids.contains(id));
1522        options.reference_root_node_ids.sort();
1523        options.reference_root_node_ids.dedup();
1524
1525        DateTime::parse_from_rfc3339(&started_at).context("session start timestamp is invalid")?;
1526        if let Some(restored_started_at) = restored
1527            .and_then(|state| state.get("startedAt"))
1528            .and_then(Value::as_str)
1529        {
1530            anyhow::ensure!(
1531                restored_started_at == started_at,
1532                "restored session start timestamp changed"
1533            );
1534        }
1535        let rust_lib_session_id = restored
1536            .and_then(|state| state.get("rustLibSessionId"))
1537            .and_then(Value::as_str)
1538            .map(str::to_owned)
1539            .or(options.rust_lib_session_id.clone())
1540            .unwrap_or_else(|| format!("kennedy:{}", Uuid::new_v4()));
1541        let history_session_id = restored
1542            .and_then(|state| state.get("sessionId"))
1543            .and_then(Value::as_str)
1544            .map(str::to_owned);
1545        let source_session_type = options.source_session_type.clone().or_else(|| {
1546            restored
1547                .and_then(|state| state.get("sourceSessionType"))
1548                .and_then(Value::as_str)
1549                .map(str::to_owned)
1550        });
1551        let session_id = history_session_id
1552            .clone()
1553            .unwrap_or_else(|| Uuid::new_v4().to_string());
1554        let metadata = SessionMetadata {
1555            session_id: session_id.clone(),
1556            kind: session_kind(&options.session_type, &options.mode),
1557            created_at: started_at.clone(),
1558            effective_context_tokens: runtime.context_window_tokens,
1559            channel: options.channel.clone(),
1560        };
1561        let mut journal = if history_session_id.is_some() {
1562            api.history_session(metadata, &runtime.model)
1563                .with_context(|| {
1564                    format!(
1565                        "opening authoritative session {session_id} (legacy snapshots are intentionally unsupported)"
1566                    )
1567                })?
1568        } else {
1569            api.create_history_session(NewSession {
1570                kind: metadata.kind,
1571                created_at: metadata.created_at,
1572                effective_context_tokens: metadata.effective_context_tokens,
1573                channel: metadata.channel,
1574            })?
1575        };
1576        let checkpoint_event_count = restored
1577            .and_then(|state| state.get("eventCount"))
1578            .and_then(Value::as_u64)
1579            .map(|count| usize::try_from(count).context("checkpoint event count is too large"))
1580            .transpose()?
1581            .unwrap_or(journal.state().events.len());
1582        anyhow::ensure!(
1583            checkpoint_event_count <= journal.state().events.len(),
1584            "checkpoint event count is ahead of the durable journal"
1585        );
1586        let fresh_ingress_attempt = matches!(options.mode, AgentMode::Ingress { .. })
1587            && !journal.is_sealed()
1588            && journal.state().history_ingress_started;
1589        if fresh_ingress_attempt {
1590            journal.reset_history_ingress_attempt(now())?;
1591        }
1592        let launch_context_node_ids = restored
1593            .and_then(|state| state.get("launchContextNodeIds"))
1594            .cloned()
1595            .map(serde_json::from_value::<Vec<String>>)
1596            .transpose()
1597            .context("restored launch context node IDs are invalid")?
1598            .unwrap_or_default();
1599        validate_canonical_distinct_ids(&launch_context_node_ids, "context node")?;
1600        let mut context = KwebContext::with_fixed_connections(
1601            options.root_node_ids.clone(),
1602            api.loads_fixed_connections(),
1603        )
1604        .map_err(anyhow::Error::new)?;
1605        restore_kweb_context(&journal, &mut context)?;
1606        let plan = if fresh_ingress_attempt {
1607            KwebPlan::default()
1608        } else {
1609            KwebPlan::restore(
1610                restored.and_then(|state| state.get("kwebPlan")),
1611                journal_kweb_plan(&journal),
1612            )?
1613        };
1614        let transcript = kcode_kennedy_session_ingress::transcript_from_journal(&journal);
1615        let (pending_turn, mut pending_external_event_id) =
1616            restore_pending_turn(restored, &transcript);
1617        let mut rounds_used = (!fresh_ingress_attempt)
1618            .then(|| {
1619                restored
1620                    .and_then(|state| state.get("roundsUsed"))
1621                    .and_then(Value::as_u64)
1622            })
1623            .flatten()
1624            .unwrap_or_default();
1625
1626        let needs_initialization = !journal
1627            .state()
1628            .boxes
1629            .values()
1630            .any(|state| matches!(state.owner, BoxOwner::System));
1631        let commit_receipt = restore_commit_receipt(restored)?;
1632        let commit_author = restored
1633            .and_then(|state| state.get("commitAuthor"))
1634            .and_then(Value::as_str)
1635            .map(str::to_owned)
1636            .unwrap_or_else(|| runtime.attribution());
1637        let provider_affinity = restore_provider_affinity(restored, fresh_ingress_attempt)?;
1638        let next_thread_reset_reason = (!fresh_ingress_attempt)
1639            .then(|| {
1640                restored
1641                    .and_then(|state| state.get("nextThreadResetReason"))
1642                    .and_then(Value::as_str)
1643                    .map(str::to_owned)
1644            })
1645            .flatten();
1646        if let Some(receipt) = &commit_receipt {
1647            journal.mark_completed(receipt.session_object_id.to_string());
1648        }
1649        let completed =
1650            journal.state().completed_session_object.is_some() || commit_receipt.is_some();
1651        let launch_provenance = restored
1652            .and_then(|state| state.get("launchProvenance"))
1653            .cloned()
1654            .unwrap_or(Value::Null);
1655        let mut launch_bootstrap_pending = restored
1656            .and_then(|state| state.get("orchestration"))
1657            .and_then(|value| value.get("launchBootstrapPending"))
1658            .and_then(Value::as_bool)
1659            .unwrap_or(!launch_provenance.is_null());
1660        let mut launch_user_turn_id = restored
1661            .and_then(|state| state.get("launchUserTurnId"))
1662            .filter(|value| !value.is_null())
1663            .cloned()
1664            .map(serde_json::from_value::<EventId>)
1665            .transpose()
1666            .context("restored launch user-turn ID is invalid")?;
1667        if let Some(id) = launch_user_turn_id {
1668            validate_user_turn_id(&journal, id)?;
1669        }
1670        let recovered_admissions =
1671            if pending_turn && checkpoint_event_count < journal.state().events.len() {
1672                recovered_user_turns(&journal, &journal.state().events[checkpoint_event_count..])?
1673            } else {
1674                Vec::new()
1675            };
1676        replay_recovered_admissions(
1677            recovered_admissions,
1678            RecoveredAdmissionReplayState {
1679                pending_turn: &pending_turn,
1680                launch_provenance: &launch_provenance,
1681                orchestration: &mut options.orchestration,
1682                launch_bootstrap_pending: &mut launch_bootstrap_pending,
1683                launch_user_turn_id: &mut launch_user_turn_id,
1684                rounds_used: &mut rounds_used,
1685                pending_external_event_id: &mut pending_external_event_id,
1686            },
1687        );
1688        let launch_intents = restored
1689            .and_then(|state| state.get("launchIntents"))
1690            .cloned()
1691            .map(serde_json::from_value::<Vec<LaunchIntent>>)
1692            .transpose()
1693            .context("restored launch intents are invalid")?
1694            .unwrap_or_default();
1695        let mut session = Self {
1696            api,
1697            subagent_codex_prompt,
1698            runtime,
1699            journal,
1700            plan,
1701            session_type: options.session_type,
1702            channel: options.channel,
1703            free_time: options.free_time,
1704            orchestration: options.orchestration,
1705            provenance_id: options.provenance_id,
1706            rust_lib_session_id,
1707            root_node_ids: options.root_node_ids,
1708            reference_root_node_ids: options.reference_root_node_ids,
1709            started_at,
1710            transcript,
1711            pending_turn,
1712            pending_external_event_id,
1713            completed,
1714            rounds_used,
1715            commit_receipt,
1716            commit_author,
1717            mode: options.mode,
1718            source_session_type,
1719            group_context: options.group_context,
1720            context,
1721            free_time_end_reason: None,
1722            fatal_persistence_error: None,
1723            active_provider_deadline: None,
1724            active_turn_deadline: None,
1725            provider_affinity,
1726            next_thread_reset_reason,
1727            ingress_deadline: None,
1728            previous_ingress_attempt_timed_out: false,
1729            launch_provenance,
1730            launch_context_node_ids,
1731            launch_user_turn_id,
1732            launch_intents,
1733            launch_bootstrap_pending,
1734            turn_lease_slot: None,
1735        };
1736        session.validate_launch_intents()?;
1737
1738        if session.journal.is_sealed() {
1739            session.provider_affinity = None;
1740            session.next_thread_reset_reason = None;
1741            anyhow::ensure!(
1742                !matches!(session.mode, AgentMode::Conversation),
1743                "a read-only conversation has an unexpectedly sealed session log"
1744            );
1745            if session.commit_receipt.is_none() {
1746                session.finalize_kweb_session()?;
1747            }
1748            session.completed = true;
1749            return Ok(session);
1750        }
1751
1752        session.reconcile_launch_intents().await?;
1753        session.prune_launch_intents();
1754        session.repair_unfinished_tools()?;
1755
1756        if needs_initialization {
1757            session.journal.create_box(
1758                now(),
1759                "Kennedy system prompt",
1760                BoxOwner::System,
1761                BoxContent::text(&system_prompt),
1762            )?;
1763            if session.session_type == "telegram-group" && !session.group_context.is_null() {
1764                session.journal.create_box(
1765                    now(),
1766                    "Telegram group context",
1767                    BoxOwner::Controller,
1768                    BoxContent::text(kcode_telegram_session_coordinator::format_group_context(
1769                        &session.group_context,
1770                    )),
1771                )?;
1772            }
1773            let identifiers = session.initial_context_identifiers();
1774            let invocation =
1775                session.record_tool_invocation("LoadNodes", json!({"identifiers":&identifiers}))?;
1776            let result =
1777                load_durable_batch(session.api.kmap(), &mut session.context, &identifiers)?;
1778            for id in &session.launch_context_node_ids {
1779                anyhow::ensure!(
1780                    session.context.contains_full_node(id),
1781                    "launched child context node {id} is unavailable"
1782                );
1783            }
1784            session.sync_load_kweb_boxes()?;
1785            session.record_tool_completion(
1786                Some(&invocation),
1787                json!({"ok":true,"automatic":true,"identifiers":identifiers,"result":result}),
1788            )?;
1789        } else {
1790            if !session.launch_context_node_ids.is_empty() {
1791                let identifiers = session.initial_context_identifiers();
1792                load_durable_batch(session.api.kmap(), &mut session.context, &identifiers)?;
1793                for id in &session.launch_context_node_ids {
1794                    anyhow::ensure!(
1795                        session.context.contains_full_node(id),
1796                        "launched child context node {id} is unavailable"
1797                    );
1798                }
1799            }
1800            session.sync_load_kweb_boxes()?;
1801        }
1802        if fresh_ingress_attempt {
1803            session.revalidate_loaded_nodes().await?;
1804            session.pending_turn = true;
1805        }
1806        if matches!(session.mode, AgentMode::Ingress { .. })
1807            && !session.completed
1808            && !session.journal.state().history_ingress_started
1809        {
1810            session.prepare_history_ingress(&system_prompt).await?;
1811        }
1812        Ok(session)
1813    }
1814
1815    fn initial_context_identifiers(&self) -> Vec<String> {
1816        let mut identifiers = self.root_node_ids.clone();
1817        if !self.launch_context_node_ids.is_empty() {
1818            for id in self
1819                .reference_root_node_ids
1820                .iter()
1821                .chain(self.launch_context_node_ids.iter())
1822            {
1823                if !identifiers.contains(id) {
1824                    identifiers.push(id.clone());
1825                }
1826            }
1827        }
1828        identifiers
1829    }
1830
1831    fn launch_session_authorized(&self) -> bool {
1832        self.pending_turn
1833            && self.launch_user_turn_id.is_some()
1834            && !self.launch_bootstrap_pending
1835            && matches!(self.mode, AgentMode::Conversation)
1836            && matches!(
1837                self.session_type.as_str(),
1838                "conversation" | "telegram" | "telegram-group"
1839            )
1840    }
1841
1842    fn validate_launch_intents(&self) -> anyhow::Result<()> {
1843        let mut seen = BTreeSet::new();
1844        for intent in &self.launch_intents {
1845            Uuid::parse_str(&intent.invocation_id).with_context(|| {
1846                format!("launch intent {} has an invalid UUID", intent.invocation_id)
1847            })?;
1848            anyhow::ensure!(
1849                seen.insert(intent.invocation_id.as_str()),
1850                "duplicate launch intent {}",
1851                intent.invocation_id
1852            );
1853            validate_user_turn_id(&self.journal, intent.user_turn_id)?;
1854            DateTime::parse_from_rfc3339(&intent.started_at)
1855                .context("launch intent timestamp is invalid")?;
1856            anyhow::ensure!(
1857                intent.parent_session_id == self.journal.state().metadata.session_id,
1858                "launch intent parent session changed"
1859            );
1860            anyhow::ensure!(
1861                intent.effective_context_tokens > 0,
1862                "launch intent effective context size is invalid"
1863            );
1864            validate_canonical_distinct_ids(&intent.root_node_ids, "root node")?;
1865            validate_canonical_distinct_ids(
1866                &intent.reference_root_node_ids,
1867                "reference root node",
1868            )?;
1869            validate_canonical_distinct_ids(&intent.context_node_ids, "context node")?;
1870            let _ = invocation_arguments(&self.journal, &intent.invocation_id)?;
1871        }
1872        Ok(())
1873    }
1874
1875    fn prune_launch_intents(&mut self) {
1876        let completed = completed_invocation_ids(&self.journal);
1877        self.launch_intents =
1878            pruned_launch_intents(&self.launch_intents, self.launch_user_turn_id, &completed);
1879    }
1880
1881    fn unfinished_launch_intents(&self) -> Vec<&LaunchIntent> {
1882        let completed = completed_invocation_ids(&self.journal);
1883        self.launch_intents
1884            .iter()
1885            .filter(|intent| !completed.contains(&intent.invocation_id))
1886            .collect()
1887    }
1888
1889    fn ensure_no_unfinished_launch_intents(&self) -> anyhow::Result<()> {
1890        let unfinished = self.unfinished_launch_intents();
1891        anyhow::ensure!(
1892            unfinished.is_empty(),
1893            "session has an unfinished intent-backed LaunchSession invocation"
1894        );
1895        Ok(())
1896    }
1897
1898    fn repair_unfinished_tools(&mut self) -> anyhow::Result<()> {
1899        self.ensure_no_unfinished_launch_intents()?;
1900        self.journal.repair_unfinished_tools(now())?;
1901        Ok(())
1902    }
1903
1904    fn prepare_launch_intent(
1905        &mut self,
1906        invocation: &RecordedToolInvocation,
1907        arguments: &LaunchSessionArguments,
1908    ) -> anyhow::Result<LaunchIntent> {
1909        anyhow::ensure!(
1910            self.launch_session_authorized(),
1911            "LaunchSession is unavailable without a genuine current user turn in an eligible conversation"
1912        );
1913        validate_loaded_launch_context(&self.context, &arguments.context_node_ids)?;
1914        if let Some(existing) = self
1915            .launch_intents
1916            .iter()
1917            .find(|intent| intent.invocation_id == invocation.invocation_id)
1918        {
1919            return Ok(existing.clone());
1920        }
1921        let user_turn_id = self
1922            .launch_user_turn_id
1923            .context("LaunchSession user-turn authority is missing")?;
1924        let current_count = self
1925            .launch_intents
1926            .iter()
1927            .filter(|intent| intent.user_turn_id == user_turn_id)
1928            .count();
1929        anyhow::ensure!(
1930            current_count < MAX_LAUNCH_INTENTS_PER_USER_TURN,
1931            "LaunchSession permits at most ten new sessions per genuine user turn"
1932        );
1933        let intent = LaunchIntent {
1934            invocation_id: invocation.invocation_id.clone(),
1935            user_turn_id,
1936            started_at: now(),
1937            parent_session_id: self.journal.state().metadata.session_id.clone(),
1938            effective_context_tokens: self.runtime.context_window_tokens,
1939            root_node_ids: self.root_node_ids.clone(),
1940            reference_root_node_ids: self.reference_root_node_ids.clone(),
1941            context_node_ids: arguments.context_node_ids.clone(),
1942        };
1943        self.launch_intents.push(intent.clone());
1944        Ok(intent)
1945    }
1946
1947    fn launch_request(intent: &LaunchIntent, directive: &str) -> HistoryLaunchSession {
1948        let provenance = json!({
1949            "kind":"synthetic-launch-bootstrap",
1950            "denyLaunchSession":true,
1951            "parentSessionId":intent.parent_session_id,
1952            "parentInvocationId":intent.invocation_id,
1953            "parentUserTurnId":intent.user_turn_id,
1954        });
1955        HistoryLaunchSession {
1956            session_id: intent.invocation_id.clone(),
1957            started_at: intent.started_at.clone(),
1958            effective_context_tokens: intent.effective_context_tokens,
1959            channel: json!({"kind":"browser"}),
1960            state: json!({
1961                "sessionId":intent.invocation_id,
1962                "chatendMetadata":{
1963                    "sessionId":intent.invocation_id,
1964                    "kind":SessionKind::Conversation,
1965                    "createdAt":intent.started_at,
1966                    "effectiveContextTokens":intent.effective_context_tokens,
1967                    "channel":{"kind":"browser"},
1968                },
1969                "sessionType":"conversation",
1970                "channel":{"kind":"browser"},
1971                "freeTime":Value::Null,
1972                "orchestration":{
1973                    "owner":"backend",
1974                    "status":"idle",
1975                    "launchBootstrapPending":true,
1976                },
1977                "launchProvenance":provenance,
1978                "rootNodeIds":intent.root_node_ids,
1979                "referenceRootNodeIds":intent.reference_root_node_ids,
1980                "launchContextNodeIds":intent.context_node_ids,
1981                "startedAt":intent.started_at,
1982                "pendingTurn":false,
1983                "completed":false,
1984            }),
1985            initial_message: json!({
1986                "text":directive,
1987                "metadata":{
1988                    "launchProvenance":provenance,
1989                },
1990            }),
1991        }
1992    }
1993
1994    async fn lower_launch(
1995        &self,
1996        intent: &LaunchIntent,
1997        arguments: &LaunchSessionArguments,
1998    ) -> Result<kcode_session_history::SessionLaunch, kcode_session_history::Error> {
1999        self.api
2000            .launch_session(Self::launch_request(intent, &arguments.directive))
2001            .await
2002    }
2003
2004    async fn reconcile_launch_intents(&mut self) -> anyhow::Result<()> {
2005        let completed = completed_invocation_ids(&self.journal);
2006        let unfinished = self
2007            .launch_intents
2008            .iter()
2009            .filter(|intent| !completed.contains(&intent.invocation_id))
2010            .cloned()
2011            .collect::<Vec<_>>();
2012        for intent in unfinished {
2013            let raw = invocation_arguments(&self.journal, &intent.invocation_id)?.clone();
2014            let arguments = decode_launch_session_arguments(&raw)?;
2015            let invocation = RecordedToolInvocation {
2016                invocation_id: intent.invocation_id.clone(),
2017                tool_instance: tool_instance_for_invocation(
2018                    LAUNCH_SESSION_TOOL,
2019                    &intent.invocation_id,
2020                ),
2021                tool_name: LAUNCH_SESSION_TOOL.into(),
2022            };
2023            let result = self.lower_launch(&intent, &arguments).await;
2024            complete_launch_reconciliation(&mut self.journal, &invocation, result)?;
2025        }
2026        Ok(())
2027    }
2028
2029    async fn prepare_history_ingress(&mut self, prompt: &str) -> anyhow::Result<()> {
2030        let cost_at_ingress = self.projection().status;
2031        if !self.journal.state().source_terminated {
2032            self.journal.record(
2033                now(),
2034                EventKind::SourceTerminated {
2035                    reason: "history_ingress".into(),
2036                },
2037            )?;
2038        }
2039        let system_box = self
2040            .journal
2041            .state()
2042            .boxes
2043            .values()
2044            .find(|state| matches!(state.owner, BoxOwner::System))
2045            .map(|state| state.id)
2046            .context("session has no system-prompt box")?;
2047        let recorded_at = now();
2048        self.journal
2049            .update_box(recorded_at.clone(), system_box, BoxContent::text(prompt))?;
2050        self.journal.rehydrate_box(recorded_at, system_box)?;
2051        let ingress_kind = session_kind(&self.session_type, &self.mode);
2052        if self.journal.state().metadata.effective_context_tokens
2053            != self.runtime.context_window_tokens
2054            || self.journal.state().metadata.kind != ingress_kind
2055        {
2056            self.journal
2057                .configure_context(ingress_kind, self.runtime.context_window_tokens);
2058        }
2059        self.journal.create_box(
2060            now(),
2061            "Session cost at ingress",
2062            BoxOwner::Controller,
2063            BoxContent::text(cost_summary(
2064                "session cost before history ingress",
2065                cost_at_ingress.estimated_cost_usd_nanos,
2066                cost_at_ingress.unpriced_provider_calls,
2067            )),
2068        )?;
2069        self.revalidate_loaded_nodes().await?;
2070        match kcode_history_ingress_context::prepare(&mut self.journal, now())? {
2071            HistoryIngressContextOutcome::Ready => {}
2072            HistoryIngressContextOutcome::OverCapacity {
2073                estimated_tokens,
2074                target_tokens,
2075            } => {
2076                self.journal.record(
2077                    now(),
2078                    EventKind::Note {
2079                        label: INGRESS_FORCE_COMMIT_NOTE.into(),
2080                        value: json!({
2081                            "reason":"fully_dehydrated_context_above_initial_target",
2082                            "estimatedTokens":estimated_tokens,
2083                            "initialTargetTokens":target_tokens,
2084                        }),
2085                    },
2086                )?;
2087                self.clear_launch_turn_authority();
2088                self.pending_turn = false;
2089                self.finalize_kweb_session()?;
2090                self.completed = true;
2091                return Ok(());
2092            }
2093        }
2094        self.journal
2095            .record(now(), EventKind::HistoryIngressStarted)?;
2096        self.pending_turn = true;
2097        Ok(())
2098    }
2099
2100    async fn revalidate_loaded_nodes(&mut self) -> anyhow::Result<()> {
2101        let direct = self.context.loaded_node_ids().to_vec();
2102        load_durable_batch(self.api.kmap(), &mut self.context, &direct)?;
2103        self.sync_load_kweb_boxes()?;
2104        Ok(())
2105    }
2106
2107    fn stage_user_input(&mut self, text: &str, metadata: &Value) -> Option<InputStage> {
2108        let recorded_at = now();
2109        let result = (|| -> anyhow::Result<Option<InputStage>> {
2110            let Some(staged) = kcode_kennedy_session_ingress::stage_user_input(
2111                &mut self.journal,
2112                text,
2113                metadata,
2114                &recorded_at,
2115            )?
2116            else {
2117                return Ok(None);
2118            };
2119            self.transcript.push(staged.transcript);
2120            self.recover_context_overflow(staged.external_event_id.as_deref(), &[])?;
2121            Ok(Some(InputStage::Accepted))
2122        })();
2123        match result {
2124            Ok(stage) => stage,
2125            Err(error) => {
2126                self.fatal_persistence_error = Some(error.to_string());
2127                tracing::error!(error=%error, "Could not durably stage session input");
2128                Some(InputStage::Accepted)
2129            }
2130        }
2131    }
2132
2133    pub fn append_final_user_message(&mut self, text: &str, metadata: &Value) -> bool {
2134        let accepted = self.stage_user_input(text, metadata).is_some();
2135        if accepted {
2136            self.invalidate_active_stepped_turn();
2137        }
2138        accepted
2139    }
2140
2141    pub fn stage_source_message(
2142        &mut self,
2143        kennedy: bool,
2144        text: &str,
2145        metadata: Value,
2146    ) -> anyhow::Result<()> {
2147        let staged = kcode_kennedy_session_ingress::stage_source_input(
2148            &mut self.journal,
2149            kennedy,
2150            text,
2151            metadata,
2152            &now(),
2153        )?;
2154        self.invalidate_active_stepped_turn();
2155        self.transcript.push(staged.transcript);
2156        self.recover_context_overflow(staged.external_event_id.as_deref(), &[])?;
2157        Ok(())
2158    }
2159
2160    pub fn answer_for_external_event(&self, id: &str) -> Option<&Value> {
2161        self.transcript.iter().rev().find(|entry| {
2162            is_terminal_external_response(entry)
2163                && entry.get("externalEventId").and_then(Value::as_str) == Some(id)
2164        })
2165    }
2166
2167    pub fn responses_for_external_event(&self, id: &str) -> Vec<&Value> {
2168        self.transcript
2169            .iter()
2170            .filter(|entry| {
2171                matches!(
2172                    entry.get("role").and_then(Value::as_str),
2173                    Some("kennedy" | "system")
2174                ) && entry.get("externalEventId").and_then(Value::as_str) == Some(id)
2175            })
2176            .collect()
2177    }
2178
2179    pub fn resolve_object(&mut self, object_id: &str) -> anyhow::Result<ResolvedObject> {
2180        let api = self.api.clone();
2181        kcode_kennedy_session_objects::resolve_object(
2182            &mut self.journal,
2183            object_id,
2184            move |canonical_id| api.kmap_file(canonical_id).map_err(Into::into),
2185        )
2186    }
2187
2188    fn resolve_media_object(&mut self, object_id: &str) -> anyhow::Result<ResolvedObject> {
2189        let api = self.api.clone();
2190        kcode_kennedy_session_objects::resolve_media_object(
2191            &mut self.journal,
2192            object_id,
2193            MAX_MEDIA_ENRICHMENT_BYTES,
2194            move |canonical_id| api.kmap_file(canonical_id).map_err(Into::into),
2195        )
2196    }
2197
2198    fn resolve_image_object(
2199        &mut self,
2200        object_id: &str,
2201    ) -> anyhow::Result<(Vec<u8>, String, String)> {
2202        let resolved = self.resolve_media_object(object_id)?;
2203        anyhow::ensure!(
2204            resolved.media_type.starts_with("image/"),
2205            "GenerateImage reference {object_id} is not an image"
2206        );
2207        Ok((resolved.bytes, resolved.file_name, resolved.media_type))
2208    }
2209
2210    fn recover_context_overflow(
2211        &mut self,
2212        external_event_id: Option<&str>,
2213        pinned_box_ids: &[BoxId],
2214    ) -> anyhow::Result<ContextRecovery> {
2215        let projection = self.projection();
2216        let target_tokens = self.journal.state().active_context_limit();
2217        if projection.estimated_tokens <= target_tokens {
2218            return Ok(ContextRecovery::NotNeeded);
2219        }
2220        let projection_hash = hex::encode(Sha256::digest(projection.render().as_bytes()));
2221        let already_irreducible = self
2222            .journal
2223            .state()
2224            .events
2225            .iter()
2226            .rev()
2227            .find_map(|event| match &event.kind {
2228                EventKind::Note { label, value } if label == "context_overflow_recovery" => {
2229                    Some(value)
2230                }
2231                _ => None,
2232            })
2233            .is_some_and(|value| {
2234                value.get("irreducible").and_then(Value::as_bool) == Some(true)
2235                    && value.get("limitTokens").and_then(Value::as_u64) == Some(target_tokens)
2236                    && value.get("projectionHash").and_then(Value::as_str)
2237                        == Some(projection_hash.as_str())
2238            });
2239        if already_irreducible {
2240            return Ok(ContextRecovery::Irreducible);
2241        }
2242
2243        let before_tokens = projection.estimated_tokens;
2244        let mut metadata = json!({
2245            "transcriptRole":"system",
2246            "contextOverflowWarning":true,
2247            "projectedTokens":before_tokens,
2248            "limitTokens":target_tokens,
2249        });
2250        if let Some(id) = external_event_id {
2251            metadata["externalEventId"] = json!(id);
2252        }
2253        let warning_box_id = self.journal.create_box(
2254            now(),
2255            CONTEXT_OVERFLOW_WARNING_BOX_NAME,
2256            BoxOwner::Controller,
2257            BoxContent {
2258                text: CONTEXT_OVERFLOW_WARNING.into(),
2259                objects: Vec::new(),
2260                metadata,
2261            },
2262        )?;
2263        let mut transcript = json!({
2264            "role":"system",
2265            "content":CONTEXT_OVERFLOW_WARNING,
2266            "contextOverflowWarning":true,
2267        });
2268        if let Some(id) = external_event_id {
2269            transcript["externalEventId"] = json!(id);
2270        }
2271        self.transcript.push(transcript);
2272
2273        let mut pins = pinned_box_ids.to_vec();
2274        if !pins.contains(&warning_box_id) {
2275            pins.push(warning_box_id);
2276        }
2277        let outcome = kcode_history_ingress_context::recover(&mut self.journal, now(), &pins)?;
2278        let (dehydrated_box_ids, estimated_tokens, target_tokens, irreducible) = match outcome {
2279            ContextRecoveryOutcome::Recovered {
2280                dehydrated_box_ids,
2281                estimated_tokens,
2282                target_tokens,
2283            } => (dehydrated_box_ids, estimated_tokens, target_tokens, false),
2284            ContextRecoveryOutcome::OverCapacity {
2285                dehydrated_box_ids,
2286                estimated_tokens,
2287                target_tokens,
2288            } => (dehydrated_box_ids, estimated_tokens, target_tokens, true),
2289        };
2290        let final_projection_hash =
2291            hex::encode(Sha256::digest(self.projection().render().as_bytes()));
2292        self.journal.record(
2293            now(),
2294            EventKind::Note {
2295                label: "context_overflow_recovery".into(),
2296                value: json!({
2297                    "beforeTokens":before_tokens,
2298                    "estimatedTokens":estimated_tokens,
2299                    "limitTokens":target_tokens,
2300                    "dehydratedBoxIds":dehydrated_box_ids,
2301                    "irreducible":irreducible,
2302                    "projectionHash":final_projection_hash,
2303                }),
2304            },
2305        )?;
2306        if irreducible {
2307            if matches!(self.mode, AgentMode::Ingress { .. }) {
2308                self.request_ingress_force_commit(
2309                    "irreducible_context_overflow",
2310                    estimated_tokens,
2311                )?;
2312            } else if !self.journal.state().source_terminated {
2313                self.journal.record(
2314                    now(),
2315                    EventKind::SourceTerminated {
2316                        reason: "irreducible_context_overflow".into(),
2317                    },
2318                )?;
2319            }
2320            Ok(ContextRecovery::Irreducible)
2321        } else {
2322            Ok(ContextRecovery::Recovered)
2323        }
2324    }
2325
2326    fn request_ingress_force_commit(
2327        &mut self,
2328        reason: &str,
2329        projected_tokens: u64,
2330    ) -> anyhow::Result<()> {
2331        if self.ingress_force_commit_requested() {
2332            return Ok(());
2333        }
2334        self.journal.record(
2335            now(),
2336            EventKind::Note {
2337                label: INGRESS_FORCE_COMMIT_NOTE.into(),
2338                value: json!({
2339                    "reason":reason,
2340                    "projectedTokens":projected_tokens,
2341                    "limitTokens":self.journal.state().ingress_context_limit(),
2342                }),
2343            },
2344        )?;
2345        Ok(())
2346    }
2347
2348    fn ingress_force_commit_requested(&self) -> bool {
2349        self.journal
2350            .state()
2351            .current_ingress_attempt_events()
2352            .iter()
2353            .rev()
2354            .any(|event| {
2355                matches!(
2356                    &event.kind,
2357                    EventKind::Note { label, .. } if label == INGRESS_FORCE_COMMIT_NOTE
2358                )
2359            })
2360    }
2361
2362    pub fn requires_history_ingress(&self) -> bool {
2363        matches!(self.mode, AgentMode::Conversation) && self.journal.state().source_terminated
2364    }
2365
2366    pub fn stage_free_time_opening(&mut self) -> bool {
2367        if self.pending_turn {
2368            return false;
2369        }
2370        self.invalidate_active_stepped_turn();
2371        self.launch_user_turn_id = None;
2372        self.prune_launch_intents();
2373        let mut blocks = vec![
2374            render(RenderRequest::FreeTimeOpening {
2375                free_time: &self.free_time,
2376            })
2377            .expect("free-time opening rendering is infallible"),
2378        ];
2379        if let Some(message) = self
2380            .free_time
2381            .get("handoffMessage")
2382            .and_then(Value::as_str)
2383            .filter(|message| !message.trim().is_empty())
2384        {
2385            blocks.push(format!(
2386                "Message from the previous self-time session:\n\n{message}"
2387            ));
2388        }
2389        let Some(stage) = self.stage_user_input(&blocks.join("\n\n"), &json!({"kind":"self-time"}))
2390        else {
2391            return false;
2392        };
2393        self.pending_turn = matches!(stage, InputStage::Accepted);
2394        true
2395    }
2396
2397    pub fn stage_wakeup_opening(&mut self) -> anyhow::Result<bool> {
2398        if self.pending_turn {
2399            return Ok(false);
2400        }
2401        self.invalidate_active_stepped_turn();
2402        self.launch_user_turn_id = None;
2403        self.prune_launch_intents();
2404        let marker = self
2405            .channel
2406            .get("wakeupMarker")
2407            .and_then(Value::as_str)
2408            .context("wakeup session is missing its acquired time marker")?;
2409        let marker = DateTime::parse_from_rfc3339(marker)
2410            .context("wakeup session has an invalid acquired time marker")?
2411            .with_timezone(&Utc);
2412        let text = render(RenderRequest::WakeupOpening { marker })?;
2413        let Some(stage) = self.stage_user_input(
2414            &text,
2415            &json!({"kind":"wakeup","wakeupMarker":marker.to_rfc3339()}),
2416        ) else {
2417            return Ok(false);
2418        };
2419        self.pending_turn = matches!(stage, InputStage::Accepted);
2420        Ok(true)
2421    }
2422
2423    pub fn begin_user_turn(&mut self, text: &str, metadata: &Value) -> bool {
2424        if self.pending_turn {
2425            return false;
2426        }
2427        self.invalidate_active_stepped_turn();
2428        self.launch_user_turn_id = None;
2429        self.prune_launch_intents();
2430        let first_event = self.journal.state().events.len();
2431        let Some(stage) = self.stage_user_input(text, metadata) else {
2432            return false;
2433        };
2434        debug_assert_eq!(stage, InputStage::Accepted);
2435        let user_turn =
2436            unique_user_box_event(&self.journal, &self.journal.state().events[first_event..])
2437                .ok()
2438                .flatten();
2439        self.launch_user_turn_id = user_turn_launch_authority(
2440            user_turn,
2441            &mut self.orchestration,
2442            &mut self.launch_bootstrap_pending,
2443        );
2444        self.rounds_used = 0;
2445        self.pending_turn = true;
2446        self.pending_external_event_id = metadata
2447            .get("externalEventId")
2448            .and_then(Value::as_str)
2449            .map(str::to_owned);
2450        true
2451    }
2452
2453    fn clear_launch_turn_authority(&mut self) {
2454        self.launch_user_turn_id = None;
2455        self.prune_launch_intents();
2456    }
2457
2458    pub fn reset_exhausted_turn_rounds_for_retry(&mut self) {
2459        if matches!(self.mode, AgentMode::Conversation)
2460            && self.rounds_used >= kcode_agent_runtime::DEFAULT_ROUND_LIMIT
2461        {
2462            self.invalidate_active_stepped_turn();
2463            self.rounds_used = 0;
2464        }
2465    }
2466
2467    pub fn interrupt_current_turn(&mut self) -> anyhow::Result<()> {
2468        self.ensure_no_unfinished_launch_intents()?;
2469        self.invalidate_active_stepped_turn();
2470        self.provider_affinity = None;
2471        self.next_thread_reset_reason = Some("prior_provider_turn_interrupted".into());
2472        self.repair_unfinished_tools()?;
2473        let notice = "The user stopped this agent turn.";
2474        let mut metadata = json!({"transcriptRole":"system","userStopped":true});
2475        let mut transcript_entry = json!({
2476            "role":"system",
2477            "content":notice,
2478            "userStopped":true,
2479        });
2480        if let Some(external_event_id) = &self.pending_external_event_id {
2481            metadata["externalEventId"] = json!(external_event_id);
2482            transcript_entry["externalEventId"] = json!(external_event_id);
2483        }
2484        self.journal.create_box(
2485            now(),
2486            "Turn stopped",
2487            BoxOwner::Controller,
2488            BoxContent {
2489                text: notice.into(),
2490                objects: Vec::new(),
2491                metadata,
2492            },
2493        )?;
2494        self.transcript.push(transcript_entry);
2495        self.pending_turn = false;
2496        self.pending_external_event_id = None;
2497        self.clear_launch_turn_authority();
2498        self.orchestration =
2499            json!({"owner":"backend","status":"idle","lastOutcome":"user-stopped"});
2500        Ok(())
2501    }
2502
2503    pub fn begin_pending_turn(
2504        &mut self,
2505        operation_id: Uuid,
2506        turn_deadline: Option<TurnDeadline>,
2507    ) -> anyhow::Result<Option<SessionTurn>> {
2508        if let Some(error) = self.fatal_persistence_error.take() {
2509            anyhow::bail!("session journal write failed: {error}");
2510        }
2511        if !self.pending_turn {
2512            return Ok(None);
2513        }
2514        let user_id = self
2515            .root_node_ids
2516            .first()
2517            .context("session has no user root for intelligence accounting")?
2518            .clone();
2519        let lease = TurnLease::acquire(&mut self.turn_lease_slot)?;
2520        self.active_provider_deadline = None;
2521        self.active_turn_deadline = turn_deadline;
2522        Ok(Some(SessionTurn {
2523            lease,
2524            user_id,
2525            completed_rounds: self.rounds_used,
2526            round_limit: kcode_agent_runtime::DEFAULT_ROUND_LIMIT,
2527            state: PrimaryTurnState {
2528                accounting: None,
2529                pending_freeform_write: None,
2530                deadline_after_response: false,
2531                operation_id,
2532                prepared_cache: None,
2533                provider_synchronized_after: None,
2534                restart_fresh_reason: None,
2535                exact_tool_result: false,
2536                used_tool: false,
2537                finish_requested: false,
2538                emitted_response: false,
2539                pending_capture: None,
2540            },
2541            at_yielded_boundary: false,
2542            admission_poisoned: false,
2543        }))
2544    }
2545
2546    pub async fn admit_pending_turn<C, F>(
2547        &mut self,
2548        turn: &mut SessionTurn,
2549        admission: PendingTurnAdmission,
2550        recorded_at: &str,
2551        checkpoint: &mut C,
2552    ) -> anyhow::Result<bool>
2553    where
2554        C: FnMut(Value) -> F + Send,
2555        F: Future<Output = anyhow::Result<()>> + Send,
2556    {
2557        turn.lease.validate(&self.turn_lease_slot)?;
2558        anyhow::ensure!(self.pending_turn, "the session has no current pending turn");
2559        anyhow::ensure!(
2560            turn.at_yielded_boundary,
2561            "the stepped session turn is not at a yielded boundary"
2562        );
2563        anyhow::ensure!(
2564            !turn.admission_poisoned,
2565            "the stepped session turn admission handle is poisoned"
2566        );
2567
2568        let expected_kind = match &admission {
2569            PendingTurnAdmission::User { .. } => AdmissionKind::User,
2570            PendingTurnAdmission::Source { .. } => AdmissionKind::Source,
2571        };
2572        let result = self
2573            .admit_pending_turn_staged(turn, admission, expected_kind, recorded_at, checkpoint)
2574            .await;
2575        if result.is_err() {
2576            turn.admission_poisoned = true;
2577        }
2578        result
2579    }
2580
2581    async fn admit_pending_turn_staged<C, F>(
2582        &mut self,
2583        turn: &mut SessionTurn,
2584        admission: PendingTurnAdmission,
2585        expected_kind: AdmissionKind,
2586        recorded_at: &str,
2587        checkpoint: &mut C,
2588    ) -> anyhow::Result<bool>
2589    where
2590        C: FnMut(Value) -> F + Send,
2591        F: Future<Output = anyhow::Result<()>> + Send,
2592    {
2593        let Some(staged) = stage_turn_admission(&mut self.journal, admission, recorded_at)? else {
2594            return Ok(false);
2595        };
2596
2597        let kind = staged.kind;
2598        let external_event_id = staged.external_event_id;
2599        let user_turn_id = staged.user_turn_id;
2600        self.transcript.push(staged.transcript);
2601        self.recover_context_overflow(external_event_id.as_deref(), &[])?;
2602
2603        anyhow::ensure!(
2604            kind == expected_kind,
2605            "staged turn admission kind differs from the requested kind"
2606        );
2607        match kind {
2608            AdmissionKind::User => {
2609                let user_turn_id =
2610                    user_turn_id.context("staged User admission has no user-turn ID")?;
2611                grant_marked_user_launch_authority(
2612                    user_turn_id,
2613                    &mut self.orchestration,
2614                    &mut self.launch_bootstrap_pending,
2615                    &mut self.launch_user_turn_id,
2616                );
2617                self.prune_launch_intents();
2618                self.rounds_used = 0;
2619                turn.completed_rounds = 0;
2620                self.pending_external_event_id = external_event_id;
2621            }
2622            AdmissionKind::Source => {
2623                anyhow::ensure!(
2624                    user_turn_id.is_none(),
2625                    "staged Source admission has a user-turn ID"
2626                );
2627            }
2628        }
2629
2630        checkpoint(self.snapshot()?).await?;
2631        Ok(true)
2632    }
2633
2634    async fn finish_stepped_turn<C, F>(
2635        &mut self,
2636        turn: SessionTurn,
2637        result: Option<String>,
2638        checkpoint: &mut C,
2639    ) -> anyhow::Result<TurnBoundary>
2640    where
2641        C: FnMut(Value) -> F + Send,
2642        F: Future<Output = anyhow::Result<()>> + Send,
2643    {
2644        turn.lease.validate(&self.turn_lease_slot)?;
2645        self.turn_lease_slot = None;
2646        self.clear_turn_deadlines();
2647        let output = match self.mode {
2648            AgentMode::Conversation => {
2649                if self.journal.state().source_terminated {
2650                    self.provider_affinity = None;
2651                    self.next_thread_reset_reason = None;
2652                    self.pending_turn = false;
2653                    self.pending_external_event_id = None;
2654                    self.clear_launch_turn_authority();
2655                    checkpoint(self.snapshot()?).await?;
2656                    None
2657                } else {
2658                    let Some(answer) = result else {
2659                        if self
2660                            .pending_external_event_id
2661                            .as_deref()
2662                            .and_then(|id| self.answer_for_external_event(id))
2663                            .is_some()
2664                        {
2665                            self.pending_turn = false;
2666                            self.pending_external_event_id = None;
2667                            self.clear_launch_turn_authority();
2668                            checkpoint(self.snapshot()?).await?;
2669                            return Ok(TurnBoundary::Complete(None));
2670                        }
2671                        anyhow::bail!(
2672                            "Kennedy ended a conversational turn without an assistant response"
2673                        );
2674                    };
2675                    self.pending_turn = false;
2676                    self.pending_external_event_id = None;
2677                    self.clear_launch_turn_authority();
2678                    checkpoint(self.snapshot()?).await?;
2679                    Some(answer)
2680                }
2681            }
2682            AgentMode::FreeTime | AgentMode::Wakeup | AgentMode::Ingress { .. } => {
2683                self.pending_turn = false;
2684                self.pending_external_event_id = None;
2685                self.clear_launch_turn_authority();
2686                self.finalize_kweb_session()?;
2687                self.completed = true;
2688                checkpoint(self.snapshot()?).await?;
2689                None
2690            }
2691        };
2692        Ok(TurnBoundary::Complete(output))
2693    }
2694
2695    pub async fn advance_pending_turn<C, F>(
2696        &mut self,
2697        mut turn: SessionTurn,
2698        checkpoint: &mut C,
2699    ) -> anyhow::Result<TurnBoundary>
2700    where
2701        C: FnMut(Value) -> F + Send,
2702        F: Future<Output = anyhow::Result<()>> + Send,
2703    {
2704        turn.lease.validate(&self.turn_lease_slot)?;
2705        anyhow::ensure!(
2706            !turn.admission_poisoned,
2707            "the stepped session turn admission handle is poisoned"
2708        );
2709        if turn.at_yielded_boundary {
2710            turn.at_yielded_boundary = false;
2711        }
2712        let result = self.advance_pending_turn_validated(turn, checkpoint).await;
2713        if result.is_err() {
2714            self.clear_turn_deadlines();
2715        }
2716        result
2717    }
2718
2719    async fn advance_pending_turn_validated<C, F>(
2720        &mut self,
2721        mut turn: SessionTurn,
2722        checkpoint: &mut C,
2723    ) -> anyhow::Result<TurnBoundary>
2724    where
2725        C: FnMut(Value) -> F + Send,
2726        F: Future<Output = anyhow::Result<()>> + Send,
2727    {
2728        if turn.completed_rounds >= turn.round_limit {
2729            let runtime = self.api.agent_runtime();
2730            let operation_id = turn.state.operation_id;
2731            let answer = {
2732                let mut host = KennedySessionHost {
2733                    session: self,
2734                    checkpoint,
2735                    state: &mut turn.state,
2736                };
2737                runtime
2738                    .run_session(
2739                        kcode_agent_runtime::SessionRunRequest {
2740                            user_id: turn.user_id.clone(),
2741                            operation_id,
2742                            rounds_used: turn.round_limit,
2743                            round_limit: turn.round_limit,
2744                        },
2745                        &mut host,
2746                    )
2747                    .await?
2748            };
2749            return self.finish_stepped_turn(turn, answer, checkpoint).await;
2750        }
2751
2752        let round = turn.completed_rounds + 1;
2753        let operation_id = turn.state.operation_id;
2754        let runtime = self.api.agent_runtime();
2755        let prepared = {
2756            let mut host = KennedySessionHost {
2757                session: self,
2758                checkpoint,
2759                state: &mut turn.state,
2760            };
2761            match host.prepare_round(round).await? {
2762                kcode_agent_runtime::RoundPreparation::Run(prepared) => {
2763                    let manifest_hash = hex::encode(Sha256::digest(prepared.input.as_bytes()));
2764                    host.record(kcode_agent_runtime::SessionEvent::InferenceSubmitted {
2765                        round,
2766                        manifest_hash,
2767                        model: prepared.model.clone(),
2768                    })
2769                    .await?;
2770                    prepared
2771                }
2772                kcode_agent_runtime::RoundPreparation::Complete(answer) => {
2773                    return self.finish_stepped_turn(turn, answer, checkpoint).await;
2774                }
2775            }
2776        };
2777        turn.completed_rounds = round;
2778        Ok(TurnBoundary::Await(PendingSessionInference {
2779            turn,
2780            action: Box::new(PendingInferenceAction::Start {
2781                runtime,
2782                request: kcode_agent_runtime::SessionInferenceRequest {
2783                    user_id: self
2784                        .root_node_ids
2785                        .first()
2786                        .context("session has no user root for intelligence accounting")?
2787                        .clone(),
2788                    operation_id,
2789                    round,
2790                    prepared,
2791                },
2792            }),
2793        }))
2794    }
2795
2796    pub async fn apply_inference_wake<C, F>(
2797        &mut self,
2798        wake: SessionInferenceWake,
2799        checkpoint: &mut C,
2800    ) -> anyhow::Result<TurnBoundary>
2801    where
2802        C: FnMut(Value) -> F + Send,
2803        F: Future<Output = anyhow::Result<()>> + Send,
2804    {
2805        wake.turn.lease.validate(&self.turn_lease_slot)?;
2806        let result = self.apply_inference_wake_validated(wake, checkpoint).await;
2807        if result.is_err() {
2808            self.clear_turn_deadlines();
2809        }
2810        result
2811    }
2812
2813    async fn apply_inference_wake_validated<C, F>(
2814        &mut self,
2815        wake: SessionInferenceWake,
2816        checkpoint: &mut C,
2817    ) -> anyhow::Result<TurnBoundary>
2818    where
2819        C: FnMut(Value) -> F + Send,
2820        F: Future<Output = anyhow::Result<()>> + Send,
2821    {
2822        let SessionInferenceWake { mut turn, kind } = wake;
2823        let round = turn.completed_rounds;
2824        let operation_id = turn.state.operation_id;
2825        match kind {
2826            SessionInferenceWakeKind::StartFailed(error) => {
2827                let mut host = KennedySessionHost {
2828                    session: self,
2829                    checkpoint,
2830                    state: &mut turn.state,
2831                };
2832                if let Some(receipt) = inference_error_receipt(&error) {
2833                    host.record(kcode_agent_runtime::SessionEvent::ProviderReceipt {
2834                        round,
2835                        usage: None,
2836                        receipt: Box::new(receipt),
2837                        continuation: None,
2838                    })
2839                    .await?;
2840                }
2841                Err(error)
2842            }
2843            SessionInferenceWakeKind::RespondFailed {
2844                mut inference,
2845                error,
2846            } => {
2847                let mut host = KennedySessionHost {
2848                    session: self,
2849                    checkpoint,
2850                    state: &mut turn.state,
2851                };
2852                record_unavailable_inference(&mut host, &mut inference, round).await?;
2853                Err(error)
2854            }
2855            SessionInferenceWakeKind::RespondedStop { inference } => {
2856                drop(inference);
2857                self.finish_stepped_turn(turn, None, checkpoint).await
2858            }
2859            SessionInferenceWakeKind::Event {
2860                mut inference,
2861                event,
2862            } => {
2863                let event = match event {
2864                    Ok(Some(event)) => event,
2865                    Ok(None) => {
2866                        let mut host = KennedySessionHost {
2867                            session: self,
2868                            checkpoint,
2869                            state: &mut turn.state,
2870                        };
2871                        record_unavailable_inference(&mut host, &mut inference, round).await?;
2872                        anyhow::bail!("provider ended without a terminal turn event");
2873                    }
2874                    Err(error) => {
2875                        let mut host = KennedySessionHost {
2876                            session: self,
2877                            checkpoint,
2878                            state: &mut turn.state,
2879                        };
2880                        if let Some(receipt) = inference_error_receipt(&error) {
2881                            host.record(kcode_agent_runtime::SessionEvent::ProviderReceipt {
2882                                round,
2883                                usage: None,
2884                                receipt: Box::new(receipt),
2885                                continuation: None,
2886                            })
2887                            .await?;
2888                        }
2889                        return Err(error);
2890                    }
2891                };
2892
2893                match event {
2894                    kcode_agent_runtime::SessionInferenceEvent::ProviderInput { context } => {
2895                        let mut host = KennedySessionHost {
2896                            session: self,
2897                            checkpoint,
2898                            state: &mut turn.state,
2899                        };
2900                        host.record(kcode_agent_runtime::SessionEvent::ProviderInput {
2901                            round,
2902                            context,
2903                        })
2904                        .await?;
2905                        Ok(TurnBoundary::Await(PendingSessionInference {
2906                            turn,
2907                            action: Box::new(PendingInferenceAction::Next { inference }),
2908                        }))
2909                    }
2910                    kcode_agent_runtime::SessionInferenceEvent::UsageUpdated { usage } => {
2911                        let mut host = KennedySessionHost {
2912                            session: self,
2913                            checkpoint,
2914                            state: &mut turn.state,
2915                        };
2916                        host.record(kcode_agent_runtime::SessionEvent::UsageUpdated {
2917                            round,
2918                            usage,
2919                        })
2920                        .await?;
2921                        Ok(TurnBoundary::Await(PendingSessionInference {
2922                            turn,
2923                            action: Box::new(PendingInferenceAction::Next { inference }),
2924                        }))
2925                    }
2926                    kcode_agent_runtime::SessionInferenceEvent::ToolCall { call_id, call } => {
2927                        turn.state.used_tool = true;
2928                        let call =
2929                            call.map_err(|error| anyhow::anyhow!("Invalid Ktool call: {error}"));
2930                        let mut host = KennedySessionHost {
2931                            session: self,
2932                            checkpoint,
2933                            state: &mut turn.state,
2934                        };
2935                        let outcome = match host.execute_tool(call, operation_id).await {
2936                            Ok(outcome) => outcome,
2937                            Err(error) => {
2938                                record_unavailable_inference(&mut host, &mut inference, round)
2939                                    .await?;
2940                                return Err(error);
2941                            }
2942                        };
2943                        let resume = match host.prepare_provider_resume(outcome).await {
2944                            Ok(resume) => resume,
2945                            Err(error) => {
2946                                record_unavailable_inference(&mut host, &mut inference, round)
2947                                    .await?;
2948                                return Err(error);
2949                            }
2950                        };
2951                        match resume {
2952                            kcode_agent_runtime::ProviderResume::Continue(mut outcome) => {
2953                                host.state.finish_requested |=
2954                                    outcome.ok && outcome.finish_after_round;
2955                                host.state.emitted_response |=
2956                                    outcome.ok && outcome.emitted_response;
2957                                host.state.pending_capture = outcome.capture.take();
2958                                let stop = outcome.stop;
2959                                let result = if outcome.ok {
2960                                    kcode_codex_runtime_v2::ToolResult::success(outcome.text)
2961                                } else {
2962                                    kcode_codex_runtime_v2::ToolResult::failure(outcome.text)
2963                                };
2964                                Ok(TurnBoundary::Await(PendingSessionInference {
2965                                    turn,
2966                                    action: Box::new(PendingInferenceAction::Respond {
2967                                        inference,
2968                                        call_id,
2969                                        result,
2970                                        stop,
2971                                    }),
2972                                }))
2973                            }
2974                            kcode_agent_runtime::ProviderResume::Complete(answer) => {
2975                                record_unavailable_inference(&mut host, &mut inference, round)
2976                                    .await?;
2977                                self.finish_stepped_turn(turn, answer, checkpoint).await
2978                            }
2979                            kcode_agent_runtime::ProviderResume::RestartFresh => {
2980                                record_unavailable_inference(&mut host, &mut inference, round)
2981                                    .await?;
2982                                turn.at_yielded_boundary = true;
2983                                Ok(TurnBoundary::Yield(turn))
2984                            }
2985                        }
2986                    }
2987                    kcode_agent_runtime::SessionInferenceEvent::Completed {
2988                        answer,
2989                        usage,
2990                        receipt,
2991                        continuation,
2992                    } => {
2993                        drop(inference);
2994                        let mut host = KennedySessionHost {
2995                            session: self,
2996                            checkpoint,
2997                            state: &mut turn.state,
2998                        };
2999                        host.record(kcode_agent_runtime::SessionEvent::ProviderReceipt {
3000                            round,
3001                            usage,
3002                            receipt,
3003                            continuation,
3004                        })
3005                        .await?;
3006                        let capture = host.state.pending_capture.take();
3007                        let control = if let Some(capture) = capture {
3008                            host.complete_capture(capture, answer).await?
3009                        } else {
3010                            let completion = kcode_agent_runtime::RoundCompletion {
3011                                answer,
3012                                used_tool: host.state.used_tool,
3013                                finish_requested: host.state.finish_requested,
3014                                emitted_response: host.state.emitted_response,
3015                            };
3016                            host.complete_round(completion).await?
3017                        };
3018                        host.state.used_tool = false;
3019                        host.state.finish_requested = false;
3020                        host.state.emitted_response = false;
3021                        match control {
3022                            kcode_agent_runtime::SessionControl::Continue => {
3023                                turn.at_yielded_boundary = true;
3024                                Ok(TurnBoundary::Yield(turn))
3025                            }
3026                            kcode_agent_runtime::SessionControl::Complete(answer) => {
3027                                self.finish_stepped_turn(turn, answer, checkpoint).await
3028                            }
3029                        }
3030                    }
3031                }
3032            }
3033        }
3034    }
3035
3036    pub async fn run_pending_turn<C, F>(
3037        &mut self,
3038        operation_id: Uuid,
3039        turn_deadline: Option<TurnDeadline>,
3040        mut checkpoint: C,
3041    ) -> anyhow::Result<Option<String>>
3042    where
3043        C: FnMut(Value) -> F + Send,
3044        F: Future<Output = anyhow::Result<()>> + Send,
3045    {
3046        let Some(turn) = self.begin_pending_turn(operation_id, turn_deadline)? else {
3047            return Ok(None);
3048        };
3049        let mut boundary = self.advance_pending_turn(turn, &mut checkpoint).await?;
3050        loop {
3051            boundary = match boundary {
3052                TurnBoundary::Await(pending) => {
3053                    let wake = pending.wait().await;
3054                    self.apply_inference_wake(wake, &mut checkpoint).await?
3055                }
3056                TurnBoundary::Yield(turn) => {
3057                    self.advance_pending_turn(turn, &mut checkpoint).await?
3058                }
3059                TurnBoundary::Complete(answer) => return Ok(answer),
3060            };
3061        }
3062    }
3063
3064    fn project_descendant<T>(
3065        &mut self,
3066        outcome: Result<kcode_intelligence_router::Accounted<T>, services::ApiError>,
3067    ) -> anyhow::Result<T> {
3068        match outcome {
3069            Ok(accounted) => {
3070                kcode_intelligence_chatend::record_descendant_receipt(
3071                    &mut self.journal,
3072                    &accounted.receipt,
3073                )?;
3074                Ok(accounted.value)
3075            }
3076            Err(error) => {
3077                if let Some(receipt) = &error.receipt {
3078                    kcode_intelligence_chatend::record_descendant_receipt(
3079                        &mut self.journal,
3080                        receipt,
3081                    )?;
3082                }
3083                Err(error.into())
3084            }
3085        }
3086    }
3087
3088    async fn run_subagent(
3089        &mut self,
3090        model: String,
3091        reasoning_effort: Option<String>,
3092        context_node_ids: Vec<String>,
3093        task: String,
3094        parent_operation_id: Uuid,
3095    ) -> anyhow::Result<String> {
3096        let reasoning_effort =
3097            reasoning_effort.unwrap_or_else(|| self.runtime.reasoning_effort.clone());
3098        let mut selected_node_descriptions = Vec::with_capacity(context_node_ids.len());
3099        for node_id in &context_node_ids {
3100            selected_node_descriptions.push(self.api.kmap_node(node_id)?.data.long_description);
3101        }
3102        let user_id = self
3103            .root_node_ids
3104            .first()
3105            .context("session has no user root for subagent intelligence accounting")?
3106            .clone();
3107        let timeout = self.agent_request_timeout();
3108        let runtime = self.api.agent_runtime();
3109        let provider = runtime.resolve_model(&model).await?.provider;
3110        let first_event = self.journal.state().events.len();
3111        let cost_before = self.projection().status;
3112        let subagent_context = SubagentContext::new(
3113            self.root_node_ids.clone(),
3114            self.api.loads_fixed_connections(),
3115            provider,
3116            self.subagent_codex_prompt.clone(),
3117            selected_node_descriptions,
3118        )?;
3119        let initial_sections = subagent_context.initial_sections().to_vec();
3120        let result = {
3121            let mut host = KennedySubagentHost {
3122                session: self,
3123                context: subagent_context,
3124                captures: HashMap::new(),
3125            };
3126            runtime
3127                .run(
3128                    kcode_agent_runtime::RunRequest {
3129                        user_id,
3130                        parent_operation_id,
3131                        model,
3132                        reasoning_effort,
3133                        context: initial_sections,
3134                        task,
3135                        timeout,
3136                        start_metadata: json!({"contextNodeIds":context_node_ids}),
3137                    },
3138                    &mut host,
3139                )
3140                .await
3141        };
3142        match result {
3143            Ok(result) => {
3144                let cost_after = self.projection().status;
3145                Ok(format!(
3146                    "{}\n\n[{}]",
3147                    result.answer,
3148                    cost_summary(
3149                        "subagent cost",
3150                        cost_after
3151                            .estimated_cost_usd_nanos
3152                            .saturating_sub(cost_before.estimated_cost_usd_nanos),
3153                        cost_after
3154                            .unpriced_provider_calls
3155                            .saturating_sub(cost_before.unpriced_provider_calls),
3156                    )
3157                ))
3158            }
3159            Err(error) => {
3160                let may_have_effects =
3161                    self.journal.state().events[first_event..]
3162                        .iter()
3163                        .any(|event| {
3164                            matches!(
3165                                &event.kind,
3166                                EventKind::Note { label, .. } if label == "subagent_tool_call"
3167                            )
3168                        });
3169                if may_have_effects {
3170                    Err(error.context(
3171                        "the subagent failed after making Ktool calls; some tool effects may already have occurred",
3172                    ))
3173                } else {
3174                    Err(error)
3175                }
3176            }
3177        }
3178    }
3179
3180    async fn complete_subagent_freeform_write(
3181        &mut self,
3182        context: &mut SubagentContext,
3183        request: FreeformWrite,
3184        contents: String,
3185        budget: &kcode_agent_runtime::ContextBudget,
3186    ) -> anyhow::Result<kcode_agent_runtime::ToolOutcome> {
3187        let kind = request.kind();
3188        let freeform_tool = request.write_tool();
3189        anyhow::ensure!(
3190            context.source_is_open(kind, request.name()),
3191            "{} {:?} is not open in this subagent context. Call {} first.",
3192            kind.label(),
3193            request.name(),
3194            kind.open_tool()
3195        );
3196        let backend_arguments = request.capture_subagent(&mut self.journal, &now(), contents)?;
3197        let preview = self
3198            .api
3199            .managed_source_execute(
3200                &self.rust_lib_session_id,
3201                request.preview_tool(),
3202                backend_arguments.clone(),
3203                Vec::new(),
3204            )
3205            .await?;
3206        let preview = preview
3207            .snapshot
3208            .context("subagent freeform write preview omitted its source snapshot")?;
3209        let preview_state = context.source_state(&preview);
3210        anyhow::ensure!(
3211            budget.fits_state(preview_state.key, preview_state.text),
3212            "{freeform_tool} was not run because its resulting source state would exceed the subagent context limit"
3213        );
3214        let execution = self
3215            .api
3216            .managed_source_execute(
3217                &self.rust_lib_session_id,
3218                freeform_tool,
3219                backend_arguments,
3220                Vec::new(),
3221            )
3222            .await?;
3223        let snapshot = execution
3224            .snapshot
3225            .context("subagent freeform write omitted its resulting source snapshot")?;
3226        let state = context.apply_source_snapshot(snapshot);
3227        Ok(kcode_agent_runtime::ToolOutcome {
3228            text: execution.text,
3229            ok: true,
3230            state_updates: state.update.into_iter().collect(),
3231            displayed_state_keys: Vec::new(),
3232            capture: None,
3233        })
3234    }
3235
3236    async fn complete_freeform_write(
3237        &mut self,
3238        pending: PendingFreeformWrite,
3239        contents: String,
3240    ) -> anyhow::Result<ToolOutcome> {
3241        let request = pending.request;
3242        let freeform_tool = request.write_tool();
3243        let backend_arguments =
3244            request.capture(&mut self.journal, &now(), pending.call_box_id, contents)?;
3245        let preview_result = self
3246            .api
3247            .managed_source_execute(
3248                &self.rust_lib_session_id,
3249                request.preview_tool(),
3250                backend_arguments.clone(),
3251                Vec::new(),
3252            )
3253            .await;
3254        let preview = match preview_result {
3255            Ok(preview) => preview,
3256            Err(error) => {
3257                return Ok(ToolOutcome {
3258                    text: format!("{freeform_tool} failed: {error}"),
3259                    store_result: true,
3260                    ok: false,
3261                    end_session: false,
3262                    freeform_write: None,
3263                    managed_source_snapshot: None,
3264                    exact_result: false,
3265                });
3266            }
3267        };
3268        let _preview = preview
3269            .snapshot
3270            .context("freeform write preview omitted the resulting source snapshot")?;
3271        request.source_box_id(&self.journal)?;
3272
3273        let execution_result = self
3274            .api
3275            .managed_source_execute(
3276                &self.rust_lib_session_id,
3277                freeform_tool,
3278                backend_arguments,
3279                Vec::new(),
3280            )
3281            .await;
3282        let execution = match execution_result {
3283            Ok(execution) => execution,
3284            Err(error) => {
3285                return Ok(ToolOutcome {
3286                    text: format!("{freeform_tool} failed: {error}"),
3287                    store_result: true,
3288                    ok: false,
3289                    end_session: false,
3290                    freeform_write: None,
3291                    managed_source_snapshot: None,
3292                    exact_result: false,
3293                });
3294            }
3295        };
3296        let snapshot = execution
3297            .snapshot
3298            .context("freeform write omitted the resulting source snapshot")?;
3299        apply_snapshot(&mut self.journal, &now(), snapshot)?;
3300        Ok(ToolOutcome {
3301            text: execution.text,
3302            store_result: false,
3303            ok: true,
3304            end_session: false,
3305            freeform_write: None,
3306            managed_source_snapshot: None,
3307            exact_result: false,
3308        })
3309    }
3310
3311    async fn send_telegram_dm(&mut self, arguments: &Value) -> anyhow::Result<String> {
3312        let request = kcode_telegram_session_coordinator::parse_private_request(arguments)?;
3313        let attachments = self.telegram_delivery_attachments(request.attachments)?;
3314        let caller_holds_user_lock = self.session_type == "telegram"
3315            && self.channel.get("telegramUserId").and_then(Value::as_i64)
3316                == Some(request.telegram_user_id);
3317        self.api
3318            .telegram()
3319            .send_private(kcode_telegram_session_coordinator::PrivateDelivery {
3320                telegram_user_id: request.telegram_user_id,
3321                message: request.message,
3322                attachments,
3323                caller_holds_user_lock,
3324            })
3325            .await
3326    }
3327
3328    async fn send_telegram_group_message(&mut self, arguments: &Value) -> anyhow::Result<String> {
3329        let request = kcode_telegram_session_coordinator::parse_group_request(arguments)?;
3330        let attachments = self.telegram_delivery_attachments(request.attachments)?;
3331        self.api
3332            .telegram()
3333            .send_group(kcode_telegram_session_coordinator::GroupDelivery {
3334                root_node_id: request.root_node_id,
3335                message: request.message,
3336                attachments,
3337            })
3338            .await
3339    }
3340
3341    fn telegram_delivery_attachments(
3342        &mut self,
3343        requests: Vec<kcode_telegram_session_coordinator::AttachmentRequest>,
3344    ) -> anyhow::Result<Vec<kcode_telegram_session_coordinator::Attachment>> {
3345        let api = self.api.clone();
3346        kcode_kennedy_session_objects::delivery_attachments(
3347            &mut self.journal,
3348            requests,
3349            move |canonical_id| api.kmap_file(canonical_id).map_err(Into::into),
3350        )
3351    }
3352
3353    async fn execute_tool(
3354        &mut self,
3355        call: &ToolCall,
3356        operation_id: Uuid,
3357    ) -> anyhow::Result<ToolOutcome> {
3358        self.assert_tool_allowed(&call.name)?;
3359        anyhow::ensure!(
3360            call.name != LAUNCH_SESSION_TOOL,
3361            "LaunchSession requires the checkpointed launch dispatch lane"
3362        );
3363        let decoded = decode(&call.name, &call.arguments)?;
3364        let mut end_session = false;
3365        let mut store_result = true;
3366        let mut freeform_write = None;
3367        let mut managed_source_snapshot = None;
3368        let text = match (call.name.as_str(), decoded) {
3369            ("NoteToSelf", None) => {
3370                decode_note_to_self(&call.arguments)?;
3371                store_result = false;
3372                "Note saved.".into()
3373            }
3374            ("SendTelegramDM", _) => self.send_telegram_dm(&call.arguments).await?,
3375            ("SendTelegramGroupMessage", _) => {
3376                self.send_telegram_group_message(&call.arguments).await?
3377            }
3378            (
3379                "RunSubagent",
3380                Some(DecodedTool::RunSubagent {
3381                    model,
3382                    reasoning_effort,
3383                    context_node_ids,
3384                    task,
3385                }),
3386            ) => {
3387                let first_event = self.journal.state().events.len();
3388                match self
3389                    .run_subagent(
3390                        model,
3391                        reasoning_effort,
3392                        context_node_ids,
3393                        task,
3394                        operation_id,
3395                    )
3396                    .await
3397                {
3398                    Ok(response) => response,
3399                    Err(error) => {
3400                        let may_have_effects = self.journal.state().events[first_event..]
3401                            .iter()
3402                            .any(|event| {
3403                                matches!(
3404                                    &event.kind,
3405                                    EventKind::Note { label, .. }
3406                                        if label == "subagent_tool_call"
3407                                )
3408                            });
3409                        if may_have_effects {
3410                            return Err(error.context(
3411                                "the subagent failed after making Ktool calls; some tool effects may already have occurred",
3412                            ));
3413                        }
3414                        return Err(error);
3415                    }
3416                }
3417            }
3418            ("EndSession", Some(DecodedTool::EndSession { message })) => {
3419                anyhow::ensure!(
3420                    !matches!(self.mode, AgentMode::Conversation),
3421                    "EndSession is only available during an autonomous or history-ingress session"
3422                );
3423                end_session = true;
3424                if matches!(self.mode, AgentMode::FreeTime)
3425                    && let Some(message) = message.filter(|message| !message.trim().is_empty())
3426                {
3427                    self.free_time["nextSessionMessage"] = json!(message);
3428                }
3429                "Session ending.".into()
3430            }
3431            ("DehydrateBoxes", Some(DecodedTool::BoxIds(ids))) => {
3432                self.journal.dehydrate_boxes(now(), &ids)?;
3433                format!(
3434                    "Dehydrated boxes {}.",
3435                    ids.iter()
3436                        .map(ToString::to_string)
3437                        .collect::<Vec<_>>()
3438                        .join(", ")
3439                )
3440            }
3441            ("SummarizeBox", Some(DecodedTool::SummarizeBox { box_id, summary })) => {
3442                self.journal.summarize_box(now(), box_id, summary)?;
3443                format!("Summarized box {box_id}.")
3444            }
3445            ("HydrateBox", Some(DecodedTool::BoxId(id))) => {
3446                self.journal.rehydrate_box(now(), id)?;
3447                let external_event_id = self.pending_external_event_id.clone();
3448                match self.recover_context_overflow(external_event_id.as_deref(), &[id])? {
3449                    ContextRecovery::NotNeeded => format!("Hydrated box {id}."),
3450                    ContextRecovery::Recovered => {
3451                        format!("Hydrated box {id}.\n\n{CONTEXT_OVERFLOW_WARNING}")
3452                    }
3453                    ContextRecovery::Irreducible => anyhow::bail!(CONTEXT_OVERFLOW_WARNING),
3454                }
3455            }
3456            ("BoxesIntoObjects", Some(DecodedTool::BoxIds(ids))) => {
3457                kcode_kennedy_box_text_objects::stage_box_text_objects(
3458                    &mut self.journal,
3459                    &ids,
3460                    &now(),
3461                )?
3462            }
3463            ("LoadNodes", Some(DecodedTool::LoadNodes(identifiers))) => {
3464                load_durable_batch(self.api.kmap(), &mut self.context, &identifiers)?;
3465                let changed = self.sync_load_kweb_boxes()?;
3466                store_result = false;
3467                render_load_nodes_result(
3468                    &self.journal,
3469                    &changed,
3470                    &self.runtime_budget().footer_lines(),
3471                )?
3472            }
3473            (
3474                "EmitObject",
3475                Some(DecodedTool::EmitObject {
3476                    object_id,
3477                    file_name,
3478                }),
3479            ) => {
3480                anyhow::ensure!(
3481                    matches!(self.mode, AgentMode::Conversation),
3482                    "EmitObject is only available in a conversation"
3483                );
3484                let object = self.resolve_object(&object_id)?;
3485                let file_name = file_name.unwrap_or_else(|| object.file_name.clone());
3486                if let Some(maximum) = self.channel.get("maxObjectBytes").and_then(Value::as_u64) {
3487                    anyhow::ensure!(
3488                        !object.bytes.is_empty(),
3489                        "object {object_id} is empty and cannot be sent through this channel"
3490                    );
3491                    anyhow::ensure!(
3492                        object.bytes.len() as u64 <= maximum,
3493                        "object {object_id} is {} bytes, over this channel's {maximum}-byte limit",
3494                        object.bytes.len()
3495                    );
3496                }
3497                let descriptor = json!({
3498                    "objectId":object_id,
3499                    "fileName":file_name,
3500                    "mediaType":object.media_type,
3501                    "byteLength":object.bytes.len(),
3502                });
3503                let mut metadata = json!({
3504                    "outputKind":"object",
3505                    "attachments":[descriptor.clone()],
3506                });
3507                if let Some(external_event_id) = &self.pending_external_event_id {
3508                    metadata["externalEventId"] = json!(external_event_id);
3509                }
3510                let content = BoxContent {
3511                    text: String::new(),
3512                    objects: vec![object_id.clone()],
3513                    metadata,
3514                };
3515                self.journal
3516                    .create_box(now(), "Kennedy message", BoxOwner::Kennedy, content)?;
3517                let mut transcript = json!({
3518                    "role":"kennedy",
3519                    "content":"",
3520                    "objects":[object_id],
3521                    "attachments":[descriptor],
3522                });
3523                if let Some(external_event_id) = &self.pending_external_event_id {
3524                    transcript["externalEventId"] = json!(external_event_id);
3525                }
3526                self.transcript.push(transcript);
3527                store_result = false;
3528                "Object emitted to the user.".into()
3529            }
3530            ("WebSearch", Some(DecodedTool::WebSearch { question, model })) => {
3531                let user_id = self
3532                    .root_node_ids
3533                    .first()
3534                    .context("session has no user root for intelligence accounting")?
3535                    .clone();
3536                let outcome = self
3537                    .api
3538                    .search(
3539                        &user_id,
3540                        kcode_intelligence_router::SearchRequest {
3541                            question,
3542                            model,
3543                            operation_id: Uuid::new_v4(),
3544                            parent_operation_id: Some(operation_id),
3545                        },
3546                    )
3547                    .await;
3548                let result = self.project_descendant(outcome)?;
3549                render_web_search_result(&result)?
3550            }
3551            ("WebFetch", Some(DecodedTool::WebFetch(url))) => {
3552                let user_id = self
3553                    .root_node_ids
3554                    .first()
3555                    .context("session has no user root for intelligence accounting")?;
3556                let result = self
3557                    .api
3558                    .fetch(
3559                        user_id,
3560                        kcode_intelligence_router::FetchRequest {
3561                            url,
3562                            operation_id: Uuid::new_v4(),
3563                            parent_operation_id: Some(operation_id),
3564                        },
3565                    )
3566                    .await?;
3567                render_web_fetch_result(&result)?
3568            }
3569            ("StageTelegramGroupMedia", Some(DecodedTool::StageTelegramGroupMedia(message_id))) => {
3570                let media_ref = kcode_telegram_session_coordinator::group_media_reference(
3571                    &self.group_context,
3572                    message_id,
3573                )?;
3574                let chat_id = media_ref.chat_id;
3575                let api = self.api.clone();
3576                let staged = kcode_kennedy_session_objects::stage_telegram_group_media(
3577                    &mut self.journal,
3578                    kcode_kennedy_session_objects::TelegramStageRequest {
3579                        chat_id,
3580                        message_id,
3581                        maximum_bytes: MAX_MEDIA_ENRICHMENT_BYTES,
3582                        transport_metadata: media_ref.transport_metadata(),
3583                        recorded_at: now(),
3584                    },
3585                    || api.telegram().group_message_media(chat_id, message_id),
3586                    |media_type| {
3587                        kcode_telegram_session_coordinator::group_media_file_name(
3588                            &media_ref, media_type,
3589                        )
3590                    },
3591                )?;
3592                render(RenderRequest::StagedTelegramMedia {
3593                    pending_id: &staged.descriptor.pending_id,
3594                    kind: &staged.kind,
3595                    file_name: &staged.descriptor.file_name,
3596                    media_type: &staged.descriptor.media_type,
3597                    size_bytes: staged.descriptor.size_bytes,
3598                    message_id,
3599                    reused: staged.reused,
3600                })?
3601            }
3602            (
3603                "TranscribeAudio",
3604                Some(DecodedTool::MediaEnrichment {
3605                    object_id,
3606                    model,
3607                    prompt,
3608                }),
3609            ) => {
3610                let object = self.resolve_media_object(&object_id)?;
3611                validate(ValidationRequest::TranscribableAudio(&object.media_type))?;
3612                validate(ValidationRequest::TranscriptionModel(&model))?;
3613                let user_id = self
3614                    .root_node_ids
3615                    .first()
3616                    .context("session has no user root for intelligence accounting")?
3617                    .clone();
3618                let outcome = self
3619                    .api
3620                    .transcribe_audio(
3621                        &user_id,
3622                        &model,
3623                        &prompt,
3624                        object.bytes,
3625                        object.file_name.clone(),
3626                        &object.media_type,
3627                        None,
3628                        operation_id,
3629                    )
3630                    .await;
3631                let result = self.project_descendant(outcome)?;
3632                render_audio_transcription_result(
3633                    &object.object_id,
3634                    &object.file_name,
3635                    &object.media_type,
3636                    &result,
3637                )?
3638            }
3639            (
3640                "AnnotateMedia",
3641                Some(DecodedTool::MediaEnrichment {
3642                    object_id,
3643                    model,
3644                    prompt,
3645                }),
3646            ) => {
3647                let media = self.resolve_media_object(&object_id)?;
3648                validate(ValidationRequest::Annotation {
3649                    model: &model,
3650                    media_type: &media.media_type,
3651                })?;
3652                let user_id = self
3653                    .root_node_ids
3654                    .first()
3655                    .context("session has no user root for intelligence accounting")?
3656                    .clone();
3657                let outcome = self
3658                    .api
3659                    .annotate_media(
3660                        &user_id,
3661                        &model,
3662                        &prompt,
3663                        media.bytes,
3664                        media.file_name.clone(),
3665                        &media.media_type,
3666                        operation_id,
3667                    )
3668                    .await;
3669                let result = self.project_descendant(outcome)?;
3670                render_media_annotation_result(
3671                    &media.object_id,
3672                    &media.file_name,
3673                    &media.media_type,
3674                    &result,
3675                )?
3676            }
3677            (
3678                "GenerateImage",
3679                Some(DecodedTool::GenerateImage {
3680                    model,
3681                    prompt,
3682                    reference_object_ids,
3683                }),
3684            ) => {
3685                let mut references = Vec::with_capacity(reference_object_ids.len());
3686                for object_id in &reference_object_ids {
3687                    references.push(self.resolve_image_object(object_id)?);
3688                }
3689                let user_id = self
3690                    .root_node_ids
3691                    .first()
3692                    .context("session has no user root for intelligence accounting")?
3693                    .clone();
3694                let outcome = self
3695                    .api
3696                    .generate_image(&user_id, &model, &prompt, references, operation_id)
3697                    .await;
3698                let result = self.project_descendant(outcome)?;
3699                let size = result.bytes.len();
3700                let file_name =
3701                    format!("generated-image.{}", image_extension(&result.content_type));
3702                let object_id = self.api.save_generated_image(
3703                    result.bytes,
3704                    &file_name,
3705                    &result.content_type,
3706                    &result.model,
3707                )?;
3708                format!(
3709                    "Generated image.\nObject: {object_id}\nFile: {file_name}\nContent type: {}\nSize: {size} bytes\nModel: {}\nUse EmitObject with {object_id} to deliver it.",
3710                    result.content_type, result.model
3711                )
3712            }
3713            ("ExtractDocumentText", Some(DecodedTool::ObjectId(object_id))) => {
3714                let object = self.resolve_media_object(&object_id)?;
3715                validate(ValidationRequest::ExtractableDocument {
3716                    media_type: &object.media_type,
3717                    file_name: &object.file_name,
3718                })?;
3719                let result = self
3720                    .api
3721                    .extract_document(object.bytes, object.file_name.clone(), &object.media_type)
3722                    .await?;
3723                render_document_extraction_result(&object.object_id, &object.file_name, &result)?
3724            }
3725            (name, None) if SPEECH_CLASSIFICATION_TOOLS.contains(&name) => {
3726                self.api
3727                    .execute_speech_classification_tool(name, call.arguments.clone())
3728                    .await?
3729            }
3730            (name, None) if TASK_BOARD_TOOLS.contains(&name) => {
3731                self.execute_task_board_tool(name, &call.arguments).await?
3732            }
3733            (name, Some(decoded)) if is_kweb_mutation(name) => {
3734                let (text, _) = execute_kweb_mutation(
3735                    name,
3736                    decoded,
3737                    &self.context,
3738                    &mut self.plan,
3739                    &mut self.journal,
3740                )?;
3741                self.sync_kweb_boxes()?;
3742                text
3743            }
3744            (name, None)
3745                if RUST_LIB_TOOLS.contains(&name)
3746                    || WEB_LIB_TOOLS.contains(&name)
3747                    || RUST_BIN_TOOLS.contains(&name) =>
3748            {
3749                if let Some(request) = prepare_freeform_write(&self.journal, name, &call.arguments)?
3750                {
3751                    store_result = false;
3752                    let acknowledgement = request.acknowledgement();
3753                    freeform_write = Some(request);
3754                    acknowledgement
3755                } else {
3756                    let object_ids = if name == CALL_RUST_BIN_TOOL {
3757                        decode_managed_objects(ManagedObjectArguments::RustBinary(&call.arguments))?
3758                    } else if name == ATTACH_OBJECT_WEB_LIB_TOOL {
3759                        decode_managed_objects(ManagedObjectArguments::WebLibraryAttachment(
3760                            &call.arguments,
3761                        ))?
3762                    } else {
3763                        Vec::new()
3764                    };
3765                    let mut objects = Vec::with_capacity(object_ids.len());
3766                    for object_id in object_ids {
3767                        objects.push(self.resolve_object(&object_id)?.bytes);
3768                    }
3769                    let execution = self
3770                        .api
3771                        .managed_source_execute(
3772                            &self.rust_lib_session_id,
3773                            name,
3774                            call.arguments.clone(),
3775                            objects,
3776                        )
3777                        .await?;
3778                    if let Some(snapshot) = execution.snapshot {
3779                        managed_source_snapshot = Some(snapshot);
3780                        store_result = false;
3781                    }
3782                    execution.text
3783                }
3784            }
3785            (name, Some(_)) => {
3786                anyhow::bail!("decoded contract for {name} did not match its dispatch lane")
3787            }
3788            (name, None) => anyhow::bail!("Tool {name} is not available"),
3789        };
3790        Ok(ToolOutcome {
3791            text,
3792            store_result,
3793            ok: true,
3794            end_session,
3795            freeform_write,
3796            managed_source_snapshot,
3797            exact_result: false,
3798        })
3799    }
3800
3801    async fn execute_task_board_tool(
3802        &self,
3803        name: &str,
3804        arguments: &Value,
3805    ) -> anyhow::Result<String> {
3806        let board = self
3807            .api
3808            .task_board()
3809            .context("task board is not configured")?
3810            .clone();
3811        let name = name.to_owned();
3812        let arguments = arguments.clone();
3813        let user_id = self
3814            .root_node_ids
3815            .first()
3816            .context("session has no user root for task-category lookup")?
3817            .clone();
3818        tokio::task::spawn_blocking(move || -> anyhow::Result<String> {
3819            let output = match name.as_str() {
3820                "CreateTaskCategory" => serde_json::to_string_pretty(
3821                    &board.create_category(serde_json::from_value(arguments)?)?,
3822                )?,
3823                "GetTaskCategory" => {
3824                    let call: CategoryCall = serde_json::from_value(arguments)?;
3825                    serde_json::to_string_pretty(&board.category(
3826                        &call.category_id,
3827                        kcode_task_board::BrowsePage {
3828                            user_id,
3829                            offset: call.offset,
3830                            limit: call.limit,
3831                        },
3832                    )?)?
3833                }
3834                "RemoveTaskCategory" => {
3835                    let call: CategoryId = serde_json::from_value(arguments)?;
3836                    board.remove_category(&call.category_id)?;
3837                    format!("Removed category {}.", call.category_id)
3838                }
3839                "CreateTask" => serde_json::to_string_pretty(
3840                    &board.create_task(serde_json::from_value(arguments)?)?,
3841                )?,
3842                "GetTask" => {
3843                    let call: TaskId = serde_json::from_value(arguments)?;
3844                    serde_json::to_string_pretty(&board.task(&call.task_id)?)?
3845                }
3846                "UpdateTask" => serde_json::to_string_pretty(
3847                    &board.update_task(serde_json::from_value(arguments)?)?,
3848                )?,
3849                "RemoveTask" => {
3850                    let call: TaskId = serde_json::from_value(arguments)?;
3851                    board.remove_task(&call.task_id)?;
3852                    format!("Removed task {}.", call.task_id)
3853                }
3854                "GetTopTaskOrphan" => {
3855                    let _: EmptyCall = serde_json::from_value(arguments)?;
3856                    serde_json::to_string_pretty(&board.top_orphan()?)?
3857                }
3858                _ => anyhow::bail!("Tool {name} is not a task-board operation"),
3859            };
3860            Ok(output)
3861        })
3862        .await
3863        .context("task-board worker stopped")?
3864    }
3865
3866    fn assert_tool_allowed(&self, name: &str) -> anyhow::Result<()> {
3867        let write = matches!(
3868            name,
3869            "ConnectNodes"
3870                | "ConsolidateFanout"
3871                | "SetFixedConnection"
3872                | "CreateNode"
3873                | "UpdateNode"
3874        );
3875        anyhow::ensure!(
3876            !write || !matches!(self.mode, AgentMode::Conversation),
3877            "{name} requires the global Kweb write lane and is unavailable in a read-only conversation"
3878        );
3879        if name == "EndSession" {
3880            anyhow::ensure!(
3881                !matches!(self.mode, AgentMode::Conversation),
3882                "EndSession is unavailable in a conversation"
3883            );
3884        }
3885        if name == LAUNCH_SESSION_TOOL {
3886            anyhow::ensure!(
3887                self.launch_session_authorized(),
3888                "LaunchSession is unavailable without a genuine current user turn in an eligible conversation"
3889            );
3890        }
3891        Ok(())
3892    }
3893
3894    fn sync_kweb_boxes(&mut self) -> anyhow::Result<Vec<BoxId>> {
3895        let (updates, creates) = self.plan.context_projection();
3896        self.context
3897            .sync_chatend(&mut self.journal, now(), &updates, &creates)
3898            .map_err(anyhow::Error::new)
3899    }
3900
3901    fn sync_load_kweb_boxes(&mut self) -> anyhow::Result<Vec<BoxId>> {
3902        let before = kweb_slot_box_ids(&self.journal);
3903        let (updates, creates) = self.plan.context_projection();
3904        let stale = self
3905            .context
3906            .sync_load_chatend(&mut self.journal, now(), &updates, &creates)
3907            .map_err(anyhow::Error::new)?;
3908        let after = kweb_slot_box_ids(&self.journal);
3909        Ok(load_box_changes(&before, &after, &stale))
3910    }
3911
3912    fn record_tool_invocation(
3913        &mut self,
3914        name: &str,
3915        arguments: Value,
3916    ) -> anyhow::Result<RecordedToolInvocation> {
3917        let invocation_id = Uuid::new_v4().to_string();
3918        let invocation = RecordedToolInvocation {
3919            tool_instance: tool_instance_for_invocation(name, &invocation_id),
3920            invocation_id,
3921            tool_name: name.into(),
3922        };
3923        self.journal.record(
3924            now(),
3925            EventKind::ToolInvoked {
3926                tool_instance: invocation.tool_instance.clone(),
3927                tool_name: invocation.tool_name.clone(),
3928                arguments,
3929                invocation_id: Some(invocation.invocation_id.clone()),
3930            },
3931        )?;
3932        Ok(invocation)
3933    }
3934
3935    fn record_tool_completion(
3936        &mut self,
3937        invocation: Option<&RecordedToolInvocation>,
3938        outcome: Value,
3939    ) -> anyhow::Result<EventId> {
3940        record_tool_completion_event(&mut self.journal, invocation, outcome)
3941    }
3942
3943    fn finalize_kweb_session(&mut self) -> anyhow::Result<()> {
3944        self.provider_affinity = None;
3945        self.next_thread_reset_reason = None;
3946        if self.commit_receipt.is_some() {
3947            return Ok(());
3948        }
3949        self.repair_unfinished_tools()?;
3950        self.journal.seal()?;
3951        let archive = self.journal.archive_bytes()?;
3952        let object_locations = self
3953            .journal
3954            .objects()
3955            .iter()
3956            .map(|(id, location)| (id.clone(), location.clone()))
3957            .collect::<Vec<_>>();
3958        let mut objects = BTreeMap::new();
3959        for (id, location) in object_locations {
3960            let pending_id = id.to_string();
3961            let transport_kind =
3962                kcode_kennedy_session_objects::staged_descriptor(&self.journal, &id)?
3963                    .transport_kind;
3964            let bytes = encode_file(
3965                &pending_id,
3966                location.metadata.file_name.as_deref(),
3967                &location.metadata.media_type,
3968                transport_kind.as_deref(),
3969                self.journal.read_object(&id)?,
3970            )
3971            .with_context(|| format!("encoding staged object {pending_id}"))?;
3972            anyhow::ensure!(
3973                objects.insert(pending_id.clone(), bytes).is_none(),
3974                "duplicate staged object {pending_id}"
3975            );
3976        }
3977        let material = self.plan.commit_material()?;
3978        let result = self.api.commit_kweb_session(CommitRequest {
3979            idempotency_key: self.journal.state().metadata.session_id.clone(),
3980            author: self.commit_author.clone(),
3981            source_created_at: DateTime::parse_from_rfc3339(&self.started_at)
3982                .context("session start timestamp is invalid")?
3983                .with_timezone(&Utc),
3984            archive,
3985            objects,
3986            creates: material.creates,
3987            updates: material.updates,
3988        })?;
3989        self.journal
3990            .mark_completed(result.session_object_id.to_string());
3991        self.commit_receipt = Some(result);
3992        Ok(())
3993    }
3994
3995    fn prepare_free_time_round(&mut self) -> anyhow::Result<bool> {
3996        if !matches!(self.mode, AgentMode::FreeTime) {
3997            return Ok(false);
3998        }
3999        let Some(deadline) = deadline(&self.free_time) else {
4000            return Ok(false);
4001        };
4002        if Utc::now() >= deadline {
4003            self.free_time_end_reason = Some("deadline".into());
4004            self.journal.create_box(
4005                now(),
4006                "Self-time timer",
4007                BoxOwner::Controller,
4008                BoxContent::text(
4009                    "The self-time deadline has arrived. Finish without starting more tool work.",
4010                ),
4011            )?;
4012            return Ok(true);
4013        }
4014        Ok(false)
4015    }
4016
4017    fn agent_request_timeout(&self) -> Option<Duration> {
4018        if matches!(self.mode, AgentMode::Conversation) && self.session_type == "conversation" {
4019            return Some(BROWSER_CONVERSATION_REQUEST_TIMEOUT);
4020        }
4021        if matches!(self.mode, AgentMode::Ingress { .. }) {
4022            return Some(HISTORY_INGRESS_REQUEST_TIMEOUT);
4023        }
4024        if matches!(self.mode, AgentMode::Wakeup) {
4025            return Some(WAKEUP_REQUEST_TIMEOUT);
4026        }
4027        if matches!(self.mode, AgentMode::FreeTime) {
4028            let deadline = deadline(&self.free_time)?;
4029            return Some(Duration::from_secs(
4030                (deadline - Utc::now()).num_seconds().max(1) as u64
4031                    + SELF_TIME_HARD_STOP_ALLOWANCE.as_secs(),
4032            ));
4033        }
4034        None
4035    }
4036
4037    pub fn refresh_telegram_group_context(
4038        &mut self,
4039        group_context: &Value,
4040        current_message_id: Option<&str>,
4041    ) -> anyhow::Result<()> {
4042        if self.session_type != "telegram-group" {
4043            return Ok(());
4044        }
4045        self.invalidate_active_stepped_turn();
4046        self.channel["groupContext"] = group_context.clone();
4047        self.group_context = group_context.clone();
4048        self.journal.create_box(
4049            now(),
4050            "Telegram group update",
4051            BoxOwner::Controller,
4052            BoxContent::text(kcode_telegram_session_coordinator::format_group_context(
4053                group_context,
4054            )),
4055        )?;
4056        self.recover_context_overflow(current_message_id, &[])?;
4057        Ok(())
4058    }
4059
4060    pub fn finalize_free_time(&mut self, reason: &str) -> anyhow::Result<()> {
4061        anyhow::ensure!(
4062            matches!(reason, "tool" | "deadline" | "hard-stop" | "user-stop"),
4063            "invalid self-time completion reason"
4064        );
4065        self.invalidate_active_stepped_turn();
4066        self.free_time["sliceEndedReason"] = json!(reason);
4067        self.free_time["sliceEndedAt"] = json!(now());
4068        self.pending_turn = false;
4069        self.pending_external_event_id = None;
4070        self.clear_launch_turn_authority();
4071        Ok(())
4072    }
4073
4074    pub fn commit_current_write_session(&mut self) -> anyhow::Result<()> {
4075        anyhow::ensure!(
4076            matches!(
4077                self.mode,
4078                AgentMode::FreeTime | AgentMode::Wakeup | AgentMode::Ingress { .. }
4079            ),
4080            "a read-only conversation cannot be committed as a Kweb write session"
4081        );
4082        self.invalidate_active_stepped_turn();
4083        self.finalize_kweb_session()?;
4084        self.completed = true;
4085        Ok(())
4086    }
4087
4088    pub fn snapshot(&self) -> anyhow::Result<Value> {
4089        let projection = self.projection();
4090        let submitted = self
4091            .journal
4092            .state()
4093            .current_ingress_attempt_events()
4094            .iter()
4095            .rev()
4096            .find_map(|event| {
4097                let EventKind::ProviderInputSubmitted { round, context, .. } = &event.kind else {
4098                    return None;
4099                };
4100                Some((event.recorded_at.as_str(), *round, context))
4101            });
4102        let (chatend_text, chatend_text_source, structured_material) = match submitted {
4103            Some((submitted_at, round, submitted)) => (
4104                submitted.input.clone(),
4105                "submitted",
4106                json!({
4107                    "provider":submitted.provider,
4108                    "model":submitted.model,
4109                    "reasoningEffort":submitted.reasoning_effort,
4110                    "baseInstructions":submitted.base_instructions,
4111                    "developerInstructions":submitted.developer_instructions,
4112                    "tools":submitted.tools,
4113                    "round":round,
4114                    "submittedAt":submitted_at,
4115                }),
4116            ),
4117            None => (projection.render(), "reconstructed", Value::Null),
4118        };
4119        let session_status = projection.status.clone();
4120        let completed_invocations = completed_invocation_ids(&self.journal);
4121        let launch_intents = pruned_launch_intents(
4122            &self.launch_intents,
4123            self.launch_user_turn_id,
4124            &completed_invocations,
4125        );
4126        Ok(json!({
4127            "format":"kennedy-chatend",
4128            "version":1,
4129            "stateVersion":CHECKPOINT_STATE_VERSION,
4130            "sessionId":self.journal.state().metadata.session_id,
4131            "chatendMetadata":self.journal.state().metadata,
4132            "sessionType":self.session_type,
4133            "sourceSessionType":self.source_session_type,
4134            "channel":self.channel,
4135            "freeTime":self.free_time,
4136            "orchestration":self.orchestration,
4137            "provenanceId":self.provenance_id,
4138            "launchProvenance":self.launch_provenance,
4139            "launchContextNodeIds":self.launch_context_node_ids,
4140            "launchUserTurnId":self.launch_user_turn_id,
4141            "launchIntents":launch_intents,
4142            "rustLibSessionId":self.rust_lib_session_id,
4143            "rootNodeIds":self.root_node_ids,
4144            "referenceRootNodeIds":self.reference_root_node_ids,
4145            "startedAt":self.started_at,
4146            "transcript":self.transcript,
4147            "pendingTurn":self.pending_turn,
4148            "pendingExternalEventId":self.pending_external_event_id,
4149            "roundsUsed":self.rounds_used,
4150            "providerAffinity":self.provider_affinity,
4151            "nextThreadResetReason":self.next_thread_reset_reason,
4152            "completed":self.completed,
4153            "sessionObjectId":self.journal.state().completed_session_object,
4154            "commitReceipt":self.commit_receipt,
4155            "commitAuthor":self.commit_author,
4156            "providerModel":self.runtime.model,
4157            "kwebPlan":self.plan.checkpoint_value()?,
4158            "boxCount":self.journal.state().boxes.len(),
4159            "eventCount":self.journal.state().events.len(),
4160            "boxes":self.journal.state().boxes,
4161            "events":self.journal.state().events,
4162            "context":projection,
4163            "sessionStatus":session_status,
4164            "chatendText":chatend_text,
4165            "chatendTextSource":chatend_text_source,
4166            "structuredMaterial":structured_material,
4167        }))
4168    }
4169
4170    pub async fn release_managed_sources(&self) {
4171        self.api
4172            .release_managed_sources(&self.rust_lib_session_id)
4173            .await;
4174    }
4175}
4176
4177impl<C, F> kcode_agent_runtime::SessionHost for KennedySessionHost<'_, C>
4178where
4179    C: FnMut(Value) -> F + Send,
4180    F: Future<Output = anyhow::Result<()>> + Send,
4181{
4182    fn prepare_round<'a>(
4183        &'a mut self,
4184        round: u64,
4185    ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::RoundPreparation> {
4186        Box::pin(async move {
4187            self.session.rounds_used = round;
4188            self.state.deadline_after_response = self.session.prepare_free_time_round()?;
4189            let external_event_id = self.session.pending_external_event_id.clone();
4190            if self
4191                .session
4192                .recover_context_overflow(external_event_id.as_deref(), &[])?
4193                == ContextRecovery::Irreducible
4194                || (matches!(self.session.mode, AgentMode::Ingress { .. })
4195                    && self.session.ingress_force_commit_requested())
4196            {
4197                return Ok(kcode_agent_runtime::RoundPreparation::Complete(None));
4198            }
4199            let ingress_time_remaining = self.session.ingress_time_remaining()?;
4200            let timeout = self.session.agent_request_timeout();
4201            self.session.begin_provider_call_budget(timeout);
4202            let tool_description = call_ktool_description(self.session.launch_session_authorized());
4203            let material_fingerprint = self
4204                .session
4205                .provider_material_fingerprint(&tool_description);
4206            let mut thread_reset_reason = self.session.next_thread_reset_reason.take();
4207            let mut continuation = None;
4208            let mut resume_after = None;
4209            if let Some(affinity) = &self.session.provider_affinity {
4210                if affinity.material_fingerprint == material_fingerprint {
4211                    continuation = Some(affinity.continuation.clone());
4212                    resume_after = Some(affinity.synchronized_event_id);
4213                } else {
4214                    self.session.provider_affinity = None;
4215                    thread_reset_reason = Some("provider_material_changed".into());
4216                }
4217            }
4218            if continuation.is_some() {
4219                self.session.provider_affinity = None;
4220                self.session.next_thread_reset_reason =
4221                    Some("prior_provider_turn_ambiguous".into());
4222            }
4223            let footer_lines = self.session.runtime_budget().footer_lines();
4224            let prepared = if let Some(remaining_seconds) = ingress_time_remaining {
4225                self.session
4226                    .journal
4227                    .prepare_provider_projection_with_ingress_time(
4228                        now(),
4229                        &footer_lines,
4230                        &material_fingerprint,
4231                        resume_after,
4232                        remaining_seconds,
4233                        self.session.previous_ingress_attempt_timed_out,
4234                    )?
4235            } else {
4236                self.session.journal.prepare_provider_projection(
4237                    now(),
4238                    &footer_lines,
4239                    &material_fingerprint,
4240                    resume_after,
4241                )?
4242            };
4243            if let Some(reason) = prepared.thread_reset_reason.clone() {
4244                self.session.provider_affinity = None;
4245                continuation = None;
4246                thread_reset_reason = Some(reason);
4247            }
4248            if continuation.is_none() && thread_reset_reason.is_some() {
4249                self.session.next_thread_reset_reason = thread_reset_reason.clone();
4250            }
4251            let input = prepared.projection.render();
4252            let projection_hash = hex::encode(Sha256::digest(input.as_bytes()));
4253            let provider_input_hash =
4254                hex::encode(Sha256::digest(prepared.provider_input.as_bytes()));
4255            let provider_input_bytes = prepared.provider_input.len() as u64;
4256            let thread_action = if continuation.is_some() {
4257                "resume"
4258            } else {
4259                "start"
4260            }
4261            .to_owned();
4262            self.state.prepared_cache = Some(PreparedCacheObservation {
4263                cacheable_prefix_bytes: prepared.cacheable_prefix_bytes,
4264                expectation: prepared.expectation,
4265                material_fingerprint,
4266                projection_hash,
4267                logical_input: input.clone(),
4268                provider_input_hash,
4269                provider_input_bytes,
4270                thread_action,
4271                thread_reset_reason,
4272                estimated_input_tokens: prepared.projection.estimated_tokens,
4273                raw_estimated_input_tokens: prepared.projection.raw_estimated_tokens,
4274                provider: String::new(),
4275                model: self.session.runtime.model.clone(),
4276            });
4277            Ok(kcode_agent_runtime::RoundPreparation::Run(
4278                kcode_agent_runtime::PreparedRound {
4279                    input,
4280                    provider_input: prepared.provider_input,
4281                    continuation,
4282                    model: self.session.runtime.model.clone(),
4283                    reasoning_effort: self.session.runtime.reasoning_effort.clone(),
4284                    tool_description,
4285                    timeout,
4286                },
4287            ))
4288        })
4289    }
4290
4291    fn record<'a>(
4292        &'a mut self,
4293        event: kcode_agent_runtime::SessionEvent,
4294    ) -> kcode_agent_runtime::HostFuture<'a, ()> {
4295        Box::pin(async move {
4296            match event {
4297                kcode_agent_runtime::SessionEvent::InferenceSubmitted {
4298                    manifest_hash,
4299                    model,
4300                    ..
4301                } => {
4302                    let prepared =
4303                        self.state.prepared_cache.as_ref().context(
4304                            "inference was submitted before provider context preparation",
4305                        )?;
4306                    anyhow::ensure!(
4307                        prepared.projection_hash == manifest_hash,
4308                        "provider input hash changed after context preparation"
4309                    );
4310                    self.state.accounting = Some(kcode_intelligence_chatend::TopLevelCall::new(
4311                        manifest_hash.clone(),
4312                        model,
4313                    ));
4314                    self.session.journal.record(
4315                        now(),
4316                        EventKind::InferenceSubmitted {
4317                            manifest_hash,
4318                            estimated_input_tokens: prepared.estimated_input_tokens,
4319                            raw_estimated_input_tokens: Some(prepared.raw_estimated_input_tokens),
4320                        },
4321                    )?;
4322                }
4323                kcode_agent_runtime::SessionEvent::ProviderInput { round, context } => {
4324                    let prepared = self
4325                        .state
4326                        .prepared_cache
4327                        .as_ref()
4328                        .context("provider context arrived before context preparation")?;
4329                    anyhow::ensure!(
4330                        hex::encode(Sha256::digest(context.input.as_bytes()))
4331                            == prepared.provider_input_hash,
4332                        "provider submitted transport input different from the prepared continuation delta"
4333                    );
4334                    let provider = context.provider.clone();
4335                    let model = context.model.clone();
4336                    let synchronized_after = self.session.journal.record(
4337                        now(),
4338                        EventKind::ProviderInputSubmitted {
4339                            round,
4340                            context: ProviderContext {
4341                                input: prepared.logical_input.clone(),
4342                                provider: context.provider,
4343                                model: context.model,
4344                                reasoning_effort: context.reasoning_effort,
4345                                base_instructions: context.base_instructions,
4346                                developer_instructions: context.developer_instructions,
4347                                tools: context
4348                                    .tools
4349                                    .into_iter()
4350                                    .map(|tool| ProviderToolDefinition {
4351                                        name: tool.name,
4352                                        description: tool.description,
4353                                        input_schema: tool.input_schema,
4354                                    })
4355                                    .collect(),
4356                            },
4357                            transport_input_hash: Some(prepared.provider_input_hash.clone()),
4358                            transport_input_bytes: Some(prepared.provider_input_bytes),
4359                            thread_action: Some(prepared.thread_action.clone()),
4360                            thread_reset_reason: prepared.thread_reset_reason.clone(),
4361                            cacheable_prefix_bytes: prepared.cacheable_prefix_bytes,
4362                            material_fingerprint: prepared.material_fingerprint.clone(),
4363                            cache_expectation: prepared.expectation.label().into(),
4364                            planned_invalidation_reason: prepared
4365                                .expectation
4366                                .planned_reason()
4367                                .map(str::to_owned),
4368                        },
4369                    )?;
4370                    self.state.provider_synchronized_after = Some(synchronized_after);
4371                    if let Some(prepared) = self.state.prepared_cache.as_mut() {
4372                        prepared.provider = provider;
4373                        prepared.model = model;
4374                    }
4375                }
4376                kcode_agent_runtime::SessionEvent::UsageUpdated { usage, .. } => {
4377                    self.state
4378                        .accounting
4379                        .as_mut()
4380                        .context("provider usage arrived before inference submission")?
4381                        .usage_updated(&mut self.session.journal, &now(), &usage)?;
4382                }
4383                kcode_agent_runtime::SessionEvent::ProviderReceipt {
4384                    usage,
4385                    receipt,
4386                    continuation,
4387                    ..
4388                } => {
4389                    self.state
4390                        .accounting
4391                        .take()
4392                        .context("provider receipt arrived before inference submission")?
4393                        .completed(&mut self.session.journal, &now(), usage.as_ref())?;
4394                    let prepared = self
4395                        .state
4396                        .prepared_cache
4397                        .take()
4398                        .context("provider receipt arrived before context preparation")?;
4399                    if let Some(reason) = self.state.restart_fresh_reason.take() {
4400                        anyhow::ensure!(
4401                            continuation.is_none(),
4402                            "restart-fresh receipt unexpectedly retained a native continuation"
4403                        );
4404                        self.session.provider_affinity = None;
4405                        self.session.next_thread_reset_reason = Some(reason);
4406                    } else if let Some(continuation) = continuation {
4407                        anyhow::ensure!(
4408                            receipt.provider_thread_id.as_deref()
4409                                == Some(continuation.thread_id.as_str()),
4410                            "provider receipt thread differs from continuation state"
4411                        );
4412                        let synchronized_event_id = self
4413                            .session
4414                            .journal
4415                            .state()
4416                            .events
4417                            .last()
4418                            .context("provider completion did not create a journal event")?
4419                            .id;
4420                        self.session.provider_affinity = Some(ProviderAffinityState {
4421                            continuation,
4422                            synchronized_event_id,
4423                            material_fingerprint: prepared.material_fingerprint.clone(),
4424                        });
4425                        self.session.next_thread_reset_reason = None;
4426                    } else {
4427                        self.session.provider_affinity = None;
4428                        self.session.next_thread_reset_reason = Some(
4429                            if prepared.thread_action == "resume" {
4430                                "provider_thread_resume_unavailable"
4431                            } else {
4432                                "provider_continuation_unavailable"
4433                            }
4434                            .into(),
4435                        );
4436                    }
4437                    log_primary_thread_observation(
4438                        self.state.operation_id,
4439                        self.session.rounds_used,
4440                        &self.session.runtime.model,
4441                        &prepared,
4442                        receipt.provider_thread_id.as_deref(),
4443                        usage.as_ref().map_or(0, |usage| usage.input_tokens),
4444                        usage.as_ref().map_or(0, |usage| usage.cached_input_tokens),
4445                    );
4446                }
4447            }
4448            let snapshot = self.session.snapshot()?;
4449            (self.checkpoint)(snapshot).await
4450        })
4451    }
4452
4453    fn execute_tool<'a>(
4454        &'a mut self,
4455        call: anyhow::Result<kcode_agent_runtime::ToolCall>,
4456        operation_id: Uuid,
4457    ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::SessionToolOutcome> {
4458        Box::pin(async move {
4459            if let Some(pending) = &self.state.pending_freeform_write {
4460                let text = format!(
4461                    "{} is awaiting the complete file contents; no other Ktool can run before that output.",
4462                    pending.request.write_tool()
4463                );
4464                self.session
4465                    .record_tool_completion(None, json!({"ok":false,"result":text}))?;
4466                return Ok(kcode_agent_runtime::SessionToolOutcome {
4467                    text,
4468                    ok: false,
4469                    capture: Some(json!(true)),
4470                    stop: false,
4471                    finish_after_round: false,
4472                    emitted_response: false,
4473                });
4474            }
4475            let tool_started_at = std::time::Instant::now();
4476            let mut created_call_box_id = None;
4477            let mut recorded_invocation = None;
4478            let transcript_start = self.session.transcript.len();
4479            let mut emitted_response = false;
4480            let mut outcome = match call {
4481                Ok(call) => {
4482                    let call = ToolCall {
4483                        name: call.name,
4484                        arguments: call.arguments,
4485                    };
4486                    let call_name = format!("Kennedy tool call: {}", call.name);
4487                    let call_content = tool_invocation_content(&call.name, &call.arguments)?;
4488                    recorded_invocation = Some(
4489                        self.session
4490                            .record_tool_invocation(&call.name, call.arguments.clone())?,
4491                    );
4492                    created_call_box_id = Some(self.session.journal.create_box(
4493                        now(),
4494                        call_name,
4495                        BoxOwner::Kennedy,
4496                        call_content,
4497                    )?);
4498                    let external_event_id = self.session.pending_external_event_id.clone();
4499                    if self
4500                        .session
4501                        .recover_context_overflow(external_event_id.as_deref(), &[])?
4502                        == ContextRecovery::Irreducible
4503                    {
4504                        ToolOutcome {
4505                            text: CONTEXT_OVERFLOW_WARNING.into(),
4506                            store_result: false,
4507                            ok: false,
4508                            end_session: false,
4509                            freeform_write: None,
4510                            managed_source_snapshot: None,
4511                            exact_result: false,
4512                        }
4513                    } else if call.name == LAUNCH_SESSION_TOOL {
4514                        let invocation = recorded_invocation
4515                            .as_ref()
4516                            .context("LaunchSession invocation was not recorded")?;
4517                        let result =
4518                            (|| -> anyhow::Result<(LaunchSessionArguments, LaunchIntent)> {
4519                                self.session.assert_tool_allowed(LAUNCH_SESSION_TOOL)?;
4520                                let arguments = decode_launch_session_arguments(&call.arguments)?;
4521                                let intent =
4522                                    self.session.prepare_launch_intent(invocation, &arguments)?;
4523                                Ok((arguments, intent))
4524                            })();
4525                        match result {
4526                            Ok((arguments, intent)) => {
4527                                (self.checkpoint)(self.session.snapshot()?).await?;
4528                                match self.session.lower_launch(&intent, &arguments).await {
4529                                    Ok(launch) => ToolOutcome {
4530                                        text: launch_success_json(
4531                                            &launch.session_id,
4532                                            &launch.command_id,
4533                                        )?,
4534                                        store_result: true,
4535                                        ok: true,
4536                                        end_session: false,
4537                                        freeform_write: None,
4538                                        managed_source_snapshot: None,
4539                                        exact_result: true,
4540                                    },
4541                                    Err(error)
4542                                        if matches!(
4543                                            error.kind,
4544                                            HistoryErrorKind::InvalidInput
4545                                                | HistoryErrorKind::Conflict
4546                                        ) =>
4547                                    {
4548                                        ToolOutcome {
4549                                            text: format!(
4550                                                "LaunchSession failed: {}",
4551                                                error.message
4552                                            ),
4553                                            store_result: true,
4554                                            ok: false,
4555                                            end_session: false,
4556                                            freeform_write: None,
4557                                            managed_source_snapshot: None,
4558                                            exact_result: false,
4559                                        }
4560                                    }
4561                                    Err(error) => {
4562                                        return Err(anyhow::anyhow!(
4563                                            "LaunchSession remains unresolved ({}): {}",
4564                                            error.kind.code(),
4565                                            error.message
4566                                        ));
4567                                    }
4568                                }
4569                            }
4570                            Err(error) => ToolOutcome {
4571                                text: format!("LaunchSession failed: {error}"),
4572                                store_result: true,
4573                                ok: false,
4574                                end_session: false,
4575                                freeform_write: None,
4576                                managed_source_snapshot: None,
4577                                exact_result: false,
4578                            },
4579                        }
4580                    } else {
4581                        match self.session.execute_tool(&call, operation_id).await {
4582                            Ok(outcome) => {
4583                                emitted_response = call.name == "EmitObject" && outcome.ok;
4584                                outcome
4585                            }
4586                            Err(error) => ToolOutcome {
4587                                text: format!("{} failed: {error}", call.name),
4588                                store_result: call.name != "LoadNodes",
4589                                ok: false,
4590                                end_session: false,
4591                                freeform_write: None,
4592                                managed_source_snapshot: None,
4593                                exact_result: false,
4594                            },
4595                        }
4596                    }
4597                }
4598                Err(error) => ToolOutcome {
4599                    text: error.to_string(),
4600                    store_result: true,
4601                    ok: false,
4602                    end_session: false,
4603                    freeform_write: None,
4604                    managed_source_snapshot: None,
4605                    exact_result: false,
4606                },
4607            };
4608            if let Some(snapshot) = outcome.managed_source_snapshot.take() {
4609                apply_snapshot(&mut self.session.journal, &now(), snapshot)?;
4610                outcome.store_result = false;
4611            }
4612            if !outcome.exact_result {
4613                append_slow_tool_duration(&mut outcome.text, tool_started_at.elapsed());
4614            }
4615            self.state.exact_tool_result = outcome.exact_result;
4616            let capture = if let Some(request) = outcome.freeform_write.take() {
4617                self.state.pending_freeform_write = Some(PendingFreeformWrite {
4618                    request,
4619                    call_box_id: created_call_box_id
4620                        .context("freeform write call box was not created")?,
4621                });
4622                Some(json!(true))
4623            } else {
4624                None
4625            };
4626            if outcome.store_result {
4627                outcome.text = ensure_tool_result_box(
4628                    &mut self.session.journal,
4629                    recorded_invocation.as_ref(),
4630                    &outcome.text,
4631                    outcome.ok,
4632                )?;
4633            }
4634            let external_event_id = self.session.pending_external_event_id.clone();
4635            let recovery = self
4636                .session
4637                .recover_context_overflow(external_event_id.as_deref(), &[])?;
4638            let context_warning_added =
4639                self.session.transcript[transcript_start..]
4640                    .iter()
4641                    .any(|entry| {
4642                        entry.get("contextOverflowWarning").and_then(Value::as_bool) == Some(true)
4643                    });
4644            let mut provider_text = outcome.text.clone();
4645            if !outcome.exact_result
4646                && context_warning_added
4647                && !provider_text.contains(CONTEXT_OVERFLOW_WARNING)
4648            {
4649                if !provider_text.is_empty() {
4650                    provider_text.push_str("\n\n");
4651                }
4652                provider_text.push_str(CONTEXT_OVERFLOW_WARNING);
4653            }
4654            self.session.record_tool_completion(
4655                recorded_invocation.as_ref(),
4656                json!({"ok":outcome.ok,"result":outcome.text}),
4657            )?;
4658            let stop = recovery == ContextRecovery::Irreducible
4659                || (matches!(self.session.mode, AgentMode::Ingress { .. })
4660                    && self.session.ingress_force_commit_requested())
4661                || (!matches!(self.session.mode, AgentMode::Ingress { .. })
4662                    && self.session.journal.state().source_terminated);
4663            Ok(kcode_agent_runtime::SessionToolOutcome {
4664                text: provider_text,
4665                ok: outcome.ok,
4666                capture,
4667                stop,
4668                finish_after_round: outcome.end_session,
4669                emitted_response,
4670            })
4671        })
4672    }
4673
4674    fn prepare_provider_resume<'a>(
4675        &'a mut self,
4676        mut outcome: kcode_agent_runtime::SessionToolOutcome,
4677    ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::ProviderResume> {
4678        Box::pin(async move {
4679            if completes_before_provider_resume(&outcome) {
4680                (self.checkpoint)(self.session.snapshot()?).await?;
4681                return Ok(kcode_agent_runtime::ProviderResume::Complete(None));
4682            }
4683
4684            let ingress_time = self
4685                .session
4686                .ingress_time_remaining()?
4687                .map(|remaining| (remaining, self.session.previous_ingress_attempt_timed_out));
4688            let synchronized_after = self
4689                .state
4690                .provider_synchronized_after
4691                .context("provider resume was prepared before its input was recorded")?;
4692            let prepared = self.session.journal.prepare_provider_resume(
4693                now(),
4694                synchronized_after,
4695                ingress_time,
4696            )?;
4697            match apply_prepared_provider_resume(
4698                &mut self.session.provider_affinity,
4699                &mut self.session.next_thread_reset_reason,
4700                prepared,
4701            ) {
4702                NativeProviderResumePreparation::Continue { marker_lines } => {
4703                    self.state.provider_synchronized_after = Some(
4704                        self.session
4705                            .journal
4706                            .state()
4707                            .events
4708                            .last()
4709                            .context("provider resume preparation left no journal event")?
4710                            .id,
4711                    );
4712                    if self.state.exact_tool_result {
4713                        self.state.exact_tool_result = false;
4714                    } else {
4715                        let mut footer_lines = marker_lines;
4716                        footer_lines.extend(self.session.runtime_budget().footer_lines());
4717                        outcome.text = provider_tool_result_with_context_footer(
4718                            &footer_lines.join("\n"),
4719                            &outcome.text,
4720                        );
4721                    }
4722                    (self.checkpoint)(self.session.snapshot()?).await?;
4723                    Ok(kcode_agent_runtime::ProviderResume::Continue(outcome))
4724                }
4725                NativeProviderResumePreparation::RestartFresh { reason } => {
4726                    self.state.exact_tool_result = false;
4727                    self.state.restart_fresh_reason = Some(reason);
4728                    (self.checkpoint)(self.session.snapshot()?).await?;
4729                    Ok(kcode_agent_runtime::ProviderResume::RestartFresh)
4730                }
4731            }
4732        })
4733    }
4734
4735    fn complete_capture<'a>(
4736        &'a mut self,
4737        _capture: Value,
4738        contents: String,
4739    ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::SessionControl> {
4740        Box::pin(async move {
4741            let pending = self
4742                .state
4743                .pending_freeform_write
4744                .take()
4745                .context("provider completed without a pending freeform write")?;
4746            let result_metadata = pending.request.clone();
4747            let outcome = self
4748                .session
4749                .complete_freeform_write(pending, contents)
4750                .await?;
4751            if outcome.store_result {
4752                self.session.journal.create_box(
4753                    now(),
4754                    "Kennedy tool result",
4755                    BoxOwner::Controller,
4756                    BoxContent::text(&outcome.text),
4757                )?;
4758            }
4759            self.session.journal.record(
4760                now(),
4761                EventKind::Note {
4762                    label: "write_file_freeform_result".into(),
4763                    value: result_metadata.result_record(outcome.ok, &outcome.text),
4764                },
4765            )?;
4766            let external_event_id = self.session.pending_external_event_id.clone();
4767            let recovery = self
4768                .session
4769                .recover_context_overflow(external_event_id.as_deref(), &[])?;
4770            let snapshot = self.session.snapshot()?;
4771            (self.checkpoint)(snapshot).await?;
4772            if recovery == ContextRecovery::Irreducible
4773                || (matches!(self.session.mode, AgentMode::Ingress { .. })
4774                    && self.session.ingress_force_commit_requested())
4775                || (!matches!(self.session.mode, AgentMode::Ingress { .. })
4776                    && self.session.journal.state().source_terminated)
4777                || self.state.deadline_after_response
4778            {
4779                return Ok(kcode_agent_runtime::SessionControl::Complete(None));
4780            }
4781            self.session.journal.create_box(
4782                now(),
4783                controller_box_name(&self.session.mode),
4784                BoxOwner::Controller,
4785                BoxContent::text(controller_message(
4786                    &self.session.mode,
4787                    &self.session.free_time,
4788                )),
4789            )?;
4790            Ok(kcode_agent_runtime::SessionControl::Continue)
4791        })
4792    }
4793
4794    fn complete_round<'a>(
4795        &'a mut self,
4796        completion: kcode_agent_runtime::RoundCompletion,
4797    ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::SessionControl> {
4798        Box::pin(async move {
4799            let answer = completion.answer.trim().to_owned();
4800            let mut completion_recovery = ContextRecovery::NotNeeded;
4801            if !answer.is_empty() {
4802                let mut content = BoxContent::text(answer.clone());
4803                if let Some(id) = &self.session.pending_external_event_id {
4804                    content.metadata["externalEventId"] = json!(id);
4805                }
4806                self.session.journal.create_box(
4807                    now(),
4808                    "Kennedy message",
4809                    BoxOwner::Kennedy,
4810                    content,
4811                )?;
4812                let mut transcript = json!({"role":"kennedy","content":answer});
4813                if let Some(id) = &self.session.pending_external_event_id {
4814                    transcript["externalEventId"] = json!(id);
4815                }
4816                self.session.transcript.push(transcript);
4817                self.session.synchronize_provider_known_events();
4818                let external_event_id = self.session.pending_external_event_id.clone();
4819                completion_recovery = self
4820                    .session
4821                    .recover_context_overflow(external_event_id.as_deref(), &[])?;
4822            }
4823            let snapshot = self.session.snapshot()?;
4824            (self.checkpoint)(snapshot).await?;
4825            if completion_recovery == ContextRecovery::Irreducible
4826                || (matches!(self.session.mode, AgentMode::Ingress { .. })
4827                    && self.session.ingress_force_commit_requested())
4828                || (!matches!(self.session.mode, AgentMode::Ingress { .. })
4829                    && self.session.journal.state().source_terminated)
4830            {
4831                return Ok(kcode_agent_runtime::SessionControl::Complete(None));
4832            }
4833            if completion.finish_requested || self.state.deadline_after_response {
4834                return Ok(kcode_agent_runtime::SessionControl::Complete(
4835                    (!answer.is_empty()).then_some(answer),
4836                ));
4837            }
4838            if matches!(self.session.mode, AgentMode::Conversation) && !answer.is_empty() {
4839                return Ok(kcode_agent_runtime::SessionControl::Complete(Some(answer)));
4840            }
4841            if matches!(self.session.mode, AgentMode::Conversation) && completion.emitted_response {
4842                return Ok(kcode_agent_runtime::SessionControl::Complete(None));
4843            }
4844            let solo_ingress_response =
4845                matches!(self.session.mode, AgentMode::Ingress { .. }) && !answer.is_empty();
4846            anyhow::ensure!(
4847                completion.used_tool || solo_ingress_response,
4848                "provider completed without a response or tool call"
4849            );
4850            self.session.journal.create_box(
4851                now(),
4852                controller_box_name(&self.session.mode),
4853                BoxOwner::Controller,
4854                BoxContent::text(controller_message(
4855                    &self.session.mode,
4856                    &self.session.free_time,
4857                )),
4858            )?;
4859            Ok(kcode_agent_runtime::SessionControl::Continue)
4860        })
4861    }
4862}
4863
4864impl kcode_agent_runtime::Host for KennedySubagentHost<'_> {
4865    fn render_tool_call(&mut self, call: &kcode_agent_runtime::ToolCall) -> anyhow::Result<String> {
4866        Ok(tool_invocation_content(&call.name, &call.arguments)?.text)
4867    }
4868
4869    fn execute_tool<'a>(
4870        &'a mut self,
4871        call: kcode_agent_runtime::ToolCall,
4872        operation_id: Uuid,
4873        budget: kcode_agent_runtime::ContextBudget,
4874    ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::ToolOutcome> {
4875        Box::pin(async move {
4876            let call = ToolCall {
4877                name: call.name,
4878                arguments: call.arguments,
4879            };
4880            if let Some(reason) = subagent_unavailable_reason(&call.name) {
4881                return Ok(kcode_agent_runtime::ToolOutcome::failure(reason));
4882            }
4883            if budget.estimated_tokens() > budget.max_input_tokens() {
4884                return Ok(kcode_agent_runtime::ToolOutcome::failure(
4885                    "The Ktool call was not run because its retained invocation would exceed the subagent context limit.",
4886                ));
4887            }
4888            if !subagent_managed_write_fits(&self.context, &call, &budget) {
4889                return Ok(kcode_agent_runtime::ToolOutcome::failure(
4890                    "The managed-source write was not run because its resulting current state would exceed the subagent context limit.",
4891                ));
4892            }
4893
4894            let tool_started_at = std::time::Instant::now();
4895
4896            if call.name == "LoadNodes" {
4897                let Some(DecodedTool::LoadNodes(identifiers)) =
4898                    decode(&call.name, &call.arguments)?
4899                else {
4900                    return Ok(kcode_agent_runtime::ToolOutcome::failure(
4901                        "LoadNodes did not match its tool contract.",
4902                    ));
4903                };
4904                load_durable_batch(
4905                    self.session.api.kmap(),
4906                    self.context.kweb_mut(),
4907                    &identifiers,
4908                )?;
4909                let (updates, creates) = self.session.plan.context_projection();
4910                let changes = self.context.reconcile_kweb(&updates, &creates)?;
4911                let displayed_state_keys = changes.displayed_state_keys();
4912                let mut text = if changes.is_empty() {
4913                    "LoadNodes completed. The subagent Kweb projection was already current.".into()
4914                } else {
4915                    changes.display_text()
4916                };
4917                append_slow_tool_duration(&mut text, tool_started_at.elapsed());
4918                return Ok(kcode_agent_runtime::ToolOutcome {
4919                    text,
4920                    ok: true,
4921                    state_updates: changes.updates,
4922                    displayed_state_keys,
4923                    capture: None,
4924                });
4925            }
4926
4927            if is_kweb_mutation(&call.name) {
4928                self.session.assert_tool_allowed(&call.name)?;
4929                let decoded = decode(&call.name, &call.arguments)?
4930                    .with_context(|| format!("{} did not match its tool contract", call.name))?;
4931                let prior_create_count = self.session.plan.create_count();
4932                let (mut text, referenced_pending) = execute_kweb_mutation(
4933                    &call.name,
4934                    decoded,
4935                    self.context.kweb(),
4936                    &mut self.session.plan,
4937                    &mut self.session.journal,
4938                )?;
4939                self.context.include_staged_nodes(
4940                    referenced_pending
4941                        .into_iter()
4942                        .chain(self.session.plan.pending_ids_from(prior_create_count)),
4943                );
4944                let (updates, creates) = self.session.plan.context_projection();
4945                let changes = self.context.reconcile_kweb(&updates, &creates)?;
4946                append_slow_tool_duration(&mut text, tool_started_at.elapsed());
4947                return Ok(kcode_agent_runtime::ToolOutcome {
4948                    text,
4949                    ok: true,
4950                    state_updates: changes.updates,
4951                    displayed_state_keys: Vec::new(),
4952                    capture: None,
4953                });
4954            }
4955
4956            if let Some(request) = decode_freeform_write(&call.name, &call.arguments)? {
4957                if !self.context.source_is_open(request.kind(), request.name()) {
4958                    return Ok(kcode_agent_runtime::ToolOutcome::failure(format!(
4959                        "{} {:?} is not open in this subagent context. Call {} first.",
4960                        request.kind().label(),
4961                        request.name(),
4962                        request.kind().open_tool()
4963                    )));
4964                }
4965                let acknowledgement = request.acknowledgement();
4966                let id = Uuid::new_v4().to_string();
4967                self.captures.insert(id.clone(), request);
4968                return Ok(kcode_agent_runtime::ToolOutcome {
4969                    text: acknowledgement,
4970                    ok: true,
4971                    state_updates: Vec::new(),
4972                    displayed_state_keys: Vec::new(),
4973                    capture: Some(Value::String(id)),
4974                });
4975            }
4976
4977            let mut outcome = match self.session.execute_tool(&call, operation_id).await {
4978                Ok(outcome) => outcome,
4979                Err(error) => {
4980                    let mut text = format!("{} failed: {error}", call.name);
4981                    append_slow_tool_duration(&mut text, tool_started_at.elapsed());
4982                    return Ok(kcode_agent_runtime::ToolOutcome::failure(text));
4983                }
4984            };
4985            let displays_managed_snapshot = outcome
4986                .managed_source_snapshot
4987                .as_ref()
4988                .is_some_and(|snapshot| result_displays_snapshot(&outcome.text, snapshot));
4989            append_slow_tool_duration(&mut outcome.text, tool_started_at.elapsed());
4990            let (state_updates, displayed_state_keys) =
4991                if let Some(snapshot) = outcome.managed_source_snapshot.take() {
4992                    let state = self.context.apply_source_snapshot(snapshot);
4993                    let displayed = displays_managed_snapshot.then_some(state.key);
4994                    (
4995                        state.update.into_iter().collect(),
4996                        displayed.into_iter().collect(),
4997                    )
4998                } else {
4999                    (Vec::new(), Vec::new())
5000                };
5001            let capture = outcome.freeform_write.take().map(|request| {
5002                let id = Uuid::new_v4().to_string();
5003                self.captures.insert(id.clone(), request);
5004                Value::String(id)
5005            });
5006            Ok(kcode_agent_runtime::ToolOutcome {
5007                text: outcome.text,
5008                ok: outcome.ok,
5009                state_updates,
5010                displayed_state_keys,
5011                capture,
5012            })
5013        })
5014    }
5015
5016    fn complete_capture<'a>(
5017        &'a mut self,
5018        capture: Value,
5019        contents: String,
5020        budget: kcode_agent_runtime::ContextBudget,
5021    ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::ToolOutcome> {
5022        Box::pin(async move {
5023            let id = capture
5024                .as_str()
5025                .context("subagent freeform capture token is invalid")?;
5026            let request = self
5027                .captures
5028                .remove(id)
5029                .context("subagent freeform capture token is unknown")?;
5030            self.session
5031                .complete_subagent_freeform_write(&mut self.context, request, contents, &budget)
5032                .await
5033        })
5034    }
5035
5036    fn record(&mut self, event: kcode_agent_runtime::AuditEvent) -> anyhow::Result<()> {
5037        kcode_intelligence_chatend::record_subagent_event(&mut self.session.journal, &now(), &event)
5038    }
5039}
5040
5041fn cost_summary(label: &str, estimated_cost_usd_nanos: u64, unpriced_calls: u64) -> String {
5042    render(RenderRequest::CostSummary {
5043        label,
5044        estimated_cost_usd_nanos,
5045        unpriced_calls,
5046    })
5047    .expect("cost-summary rendering is infallible")
5048}
5049
5050fn restore_kweb_context(journal: &HistorySession, context: &mut KwebContext) -> anyhow::Result<()> {
5051    let Some(tool) = journal.state().tools.get(KWEB_TOOL_INSTANCE) else {
5052        return Ok(());
5053    };
5054    let mut nodes = BTreeMap::new();
5055    for slot in &tool.slots {
5056        let state = journal
5057            .state()
5058            .box_state(slot.box_id)
5059            .context("Kweb slot references a missing box")?;
5060        if let Some(node) = state.canonical.content.metadata.get("storedNode") {
5061            let node = match serde_json::from_value::<KwebNode>(node.clone()) {
5062                Ok(node) => node,
5063                Err(_) => node_from_value(node).context("decoding a stored Kweb context node")?,
5064            };
5065            nodes.insert(node.id.clone(), node);
5066        }
5067    }
5068    let mut direct = journal
5069        .state()
5070        .current_ingress_attempt_events()
5071        .iter()
5072        .flat_map(|event| {
5073            let EventKind::ToolInvoked {
5074                tool_name,
5075                arguments,
5076                ..
5077            } = &event.kind
5078            else {
5079                return Vec::new();
5080            };
5081            match tool_name.as_str() {
5082                "LoadNodes" => arguments
5083                    .get("identifiers")
5084                    .and_then(Value::as_array)
5085                    .into_iter()
5086                    .flatten()
5087                    .filter_map(Value::as_str)
5088                    .map(str::to_owned)
5089                    .collect(),
5090                "LoadNode" => arguments
5091                    .get("identifier")
5092                    .and_then(Value::as_str)
5093                    .map(str::to_owned)
5094                    .into_iter()
5095                    .collect(),
5096                _ => Vec::new(),
5097            }
5098        })
5099        .collect::<Vec<_>>();
5100    if direct.is_empty() {
5101        direct = context.root_node_ids().to_vec();
5102    }
5103    context
5104        .restore(nodes.into_values(), direct)
5105        .map_err(anyhow::Error::new)
5106}
5107
5108fn session_kind(session_type: &str, mode: &AgentMode) -> SessionKind {
5109    if matches!(mode, AgentMode::Ingress { .. }) {
5110        return SessionKind::HistoryIngress;
5111    }
5112    match session_type {
5113        "conversation" => SessionKind::Conversation,
5114        "telegram" => SessionKind::Telegram,
5115        "telegram-group" => SessionKind::TelegramGroup,
5116        "free-time" => SessionKind::SelfTime,
5117        "wakeup" => SessionKind::Other("wakeup".into()),
5118        "audio" => SessionKind::AudioIngress,
5119        other => SessionKind::Other(other.into()),
5120    }
5121}
5122
5123fn tool_instance_for_invocation(name: &str, invocation_id: &str) -> String {
5124    if name == "LoadNodes" {
5125        return KWEB_TOOL_INSTANCE.into();
5126    }
5127    format!("{name}:{invocation_id}")
5128}
5129
5130fn canonical_id(value: &str) -> anyhow::Result<String> {
5131    value
5132        .parse::<NodeId>()
5133        .with_context(|| format!("{value:?} is not a canonical node ID"))?;
5134    Ok(value.into())
5135}
5136
5137fn image_extension(media_type: &str) -> &'static str {
5138    match media_type
5139        .split(';')
5140        .next()
5141        .unwrap_or(media_type)
5142        .trim()
5143        .to_ascii_lowercase()
5144        .as_str()
5145    {
5146        "image/jpeg" => "jpg",
5147        "image/webp" => "webp",
5148        _ => "png",
5149    }
5150}
5151
5152fn call_ktool_description(include_launch_session: bool) -> String {
5153    let mut description = render(RenderRequest::CallKtoolDescription)
5154        .expect("Ktool-description rendering is infallible");
5155    if include_launch_session {
5156        description.push_str(
5157            "\n\nLaunchSession is available for this genuine user turn. Call it with exactly directive (a nonblank string) and contextNodeIds (an ordered array of distinct fully loaded canonical node IDs). It launches an ordinary browser conversation and returns exactly sessionId and commandId.",
5158        );
5159    }
5160    description
5161}
5162
5163fn now() -> String {
5164    Utc::now().to_rfc3339()
5165}
5166
5167fn deadline(value: &Value) -> Option<DateTime<Utc>> {
5168    value
5169        .get("deadlineAt")
5170        .and_then(Value::as_str)
5171        .and_then(|value| DateTime::parse_from_rfc3339(value).ok())
5172        .map(|value| value.with_timezone(&Utc))
5173}
5174
5175fn remaining_until(deadline: DateTime<Utc>) -> Duration {
5176    (deadline - Utc::now()).to_std().unwrap_or(Duration::ZERO)
5177}
5178
5179fn controller_box_name(mode: &AgentMode) -> &'static str {
5180    match mode {
5181        AgentMode::Conversation => "Turn continuation",
5182        AgentMode::FreeTime => "Self-time continuation",
5183        AgentMode::Wakeup => "Wakeup continuation",
5184        AgentMode::Ingress { .. } => "History-ingress continuation",
5185    }
5186}
5187
5188fn controller_message(mode: &AgentMode, free_time: &Value) -> String {
5189    let mode = match mode {
5190        AgentMode::Conversation => "conversation",
5191        AgentMode::FreeTime => "free-time",
5192        AgentMode::Wakeup => "wakeup",
5193        AgentMode::Ingress { .. } => "ingress",
5194    };
5195    render(RenderRequest::ControllerMessage { mode, free_time })
5196        .expect("known controller modes render successfully")
5197}