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                self.launch_user_turn_id = user_turn_launch_authority(
2612                    Some(user_turn_id),
2613                    &mut self.orchestration,
2614                    &mut self.launch_bootstrap_pending,
2615                );
2616                self.prune_launch_intents();
2617                self.rounds_used = 0;
2618                turn.completed_rounds = 0;
2619                self.pending_external_event_id = external_event_id;
2620            }
2621            AdmissionKind::Source => {
2622                anyhow::ensure!(
2623                    user_turn_id.is_none(),
2624                    "staged Source admission has a user-turn ID"
2625                );
2626            }
2627        }
2628
2629        checkpoint(self.snapshot()?).await?;
2630        Ok(true)
2631    }
2632
2633    async fn finish_stepped_turn<C, F>(
2634        &mut self,
2635        turn: SessionTurn,
2636        result: Option<String>,
2637        checkpoint: &mut C,
2638    ) -> anyhow::Result<TurnBoundary>
2639    where
2640        C: FnMut(Value) -> F + Send,
2641        F: Future<Output = anyhow::Result<()>> + Send,
2642    {
2643        turn.lease.validate(&self.turn_lease_slot)?;
2644        self.turn_lease_slot = None;
2645        self.clear_turn_deadlines();
2646        let output = match self.mode {
2647            AgentMode::Conversation => {
2648                if self.journal.state().source_terminated {
2649                    self.provider_affinity = None;
2650                    self.next_thread_reset_reason = None;
2651                    self.pending_turn = false;
2652                    self.pending_external_event_id = None;
2653                    self.clear_launch_turn_authority();
2654                    checkpoint(self.snapshot()?).await?;
2655                    None
2656                } else {
2657                    let Some(answer) = result else {
2658                        if self
2659                            .pending_external_event_id
2660                            .as_deref()
2661                            .and_then(|id| self.answer_for_external_event(id))
2662                            .is_some()
2663                        {
2664                            self.pending_turn = false;
2665                            self.pending_external_event_id = None;
2666                            self.clear_launch_turn_authority();
2667                            checkpoint(self.snapshot()?).await?;
2668                            return Ok(TurnBoundary::Complete(None));
2669                        }
2670                        anyhow::bail!(
2671                            "Kennedy ended a conversational turn without an assistant response"
2672                        );
2673                    };
2674                    self.pending_turn = false;
2675                    self.pending_external_event_id = None;
2676                    self.clear_launch_turn_authority();
2677                    checkpoint(self.snapshot()?).await?;
2678                    Some(answer)
2679                }
2680            }
2681            AgentMode::FreeTime | AgentMode::Wakeup | AgentMode::Ingress { .. } => {
2682                self.pending_turn = false;
2683                self.pending_external_event_id = None;
2684                self.clear_launch_turn_authority();
2685                self.finalize_kweb_session()?;
2686                self.completed = true;
2687                checkpoint(self.snapshot()?).await?;
2688                None
2689            }
2690        };
2691        Ok(TurnBoundary::Complete(output))
2692    }
2693
2694    pub async fn advance_pending_turn<C, F>(
2695        &mut self,
2696        mut turn: SessionTurn,
2697        checkpoint: &mut C,
2698    ) -> anyhow::Result<TurnBoundary>
2699    where
2700        C: FnMut(Value) -> F + Send,
2701        F: Future<Output = anyhow::Result<()>> + Send,
2702    {
2703        turn.lease.validate(&self.turn_lease_slot)?;
2704        anyhow::ensure!(
2705            !turn.admission_poisoned,
2706            "the stepped session turn admission handle is poisoned"
2707        );
2708        if turn.at_yielded_boundary {
2709            turn.at_yielded_boundary = false;
2710        }
2711        let result = self.advance_pending_turn_validated(turn, checkpoint).await;
2712        if result.is_err() {
2713            self.clear_turn_deadlines();
2714        }
2715        result
2716    }
2717
2718    async fn advance_pending_turn_validated<C, F>(
2719        &mut self,
2720        mut turn: SessionTurn,
2721        checkpoint: &mut C,
2722    ) -> anyhow::Result<TurnBoundary>
2723    where
2724        C: FnMut(Value) -> F + Send,
2725        F: Future<Output = anyhow::Result<()>> + Send,
2726    {
2727        if turn.completed_rounds >= turn.round_limit {
2728            let runtime = self.api.agent_runtime();
2729            let operation_id = turn.state.operation_id;
2730            let answer = {
2731                let mut host = KennedySessionHost {
2732                    session: self,
2733                    checkpoint,
2734                    state: &mut turn.state,
2735                };
2736                runtime
2737                    .run_session(
2738                        kcode_agent_runtime::SessionRunRequest {
2739                            user_id: turn.user_id.clone(),
2740                            operation_id,
2741                            rounds_used: turn.round_limit,
2742                            round_limit: turn.round_limit,
2743                        },
2744                        &mut host,
2745                    )
2746                    .await?
2747            };
2748            return self.finish_stepped_turn(turn, answer, checkpoint).await;
2749        }
2750
2751        let round = turn.completed_rounds + 1;
2752        let operation_id = turn.state.operation_id;
2753        let runtime = self.api.agent_runtime();
2754        let prepared = {
2755            let mut host = KennedySessionHost {
2756                session: self,
2757                checkpoint,
2758                state: &mut turn.state,
2759            };
2760            match host.prepare_round(round).await? {
2761                kcode_agent_runtime::RoundPreparation::Run(prepared) => {
2762                    let manifest_hash = hex::encode(Sha256::digest(prepared.input.as_bytes()));
2763                    host.record(kcode_agent_runtime::SessionEvent::InferenceSubmitted {
2764                        round,
2765                        manifest_hash,
2766                        model: prepared.model.clone(),
2767                    })
2768                    .await?;
2769                    prepared
2770                }
2771                kcode_agent_runtime::RoundPreparation::Complete(answer) => {
2772                    return self.finish_stepped_turn(turn, answer, checkpoint).await;
2773                }
2774            }
2775        };
2776        turn.completed_rounds = round;
2777        Ok(TurnBoundary::Await(PendingSessionInference {
2778            turn,
2779            action: Box::new(PendingInferenceAction::Start {
2780                runtime,
2781                request: kcode_agent_runtime::SessionInferenceRequest {
2782                    user_id: self
2783                        .root_node_ids
2784                        .first()
2785                        .context("session has no user root for intelligence accounting")?
2786                        .clone(),
2787                    operation_id,
2788                    round,
2789                    prepared,
2790                },
2791            }),
2792        }))
2793    }
2794
2795    pub async fn apply_inference_wake<C, F>(
2796        &mut self,
2797        wake: SessionInferenceWake,
2798        checkpoint: &mut C,
2799    ) -> anyhow::Result<TurnBoundary>
2800    where
2801        C: FnMut(Value) -> F + Send,
2802        F: Future<Output = anyhow::Result<()>> + Send,
2803    {
2804        wake.turn.lease.validate(&self.turn_lease_slot)?;
2805        let result = self.apply_inference_wake_validated(wake, checkpoint).await;
2806        if result.is_err() {
2807            self.clear_turn_deadlines();
2808        }
2809        result
2810    }
2811
2812    async fn apply_inference_wake_validated<C, F>(
2813        &mut self,
2814        wake: SessionInferenceWake,
2815        checkpoint: &mut C,
2816    ) -> anyhow::Result<TurnBoundary>
2817    where
2818        C: FnMut(Value) -> F + Send,
2819        F: Future<Output = anyhow::Result<()>> + Send,
2820    {
2821        let SessionInferenceWake { mut turn, kind } = wake;
2822        let round = turn.completed_rounds;
2823        let operation_id = turn.state.operation_id;
2824        match kind {
2825            SessionInferenceWakeKind::StartFailed(error) => {
2826                let mut host = KennedySessionHost {
2827                    session: self,
2828                    checkpoint,
2829                    state: &mut turn.state,
2830                };
2831                if let Some(receipt) = inference_error_receipt(&error) {
2832                    host.record(kcode_agent_runtime::SessionEvent::ProviderReceipt {
2833                        round,
2834                        usage: None,
2835                        receipt: Box::new(receipt),
2836                        continuation: None,
2837                    })
2838                    .await?;
2839                }
2840                Err(error)
2841            }
2842            SessionInferenceWakeKind::RespondFailed {
2843                mut inference,
2844                error,
2845            } => {
2846                let mut host = KennedySessionHost {
2847                    session: self,
2848                    checkpoint,
2849                    state: &mut turn.state,
2850                };
2851                record_unavailable_inference(&mut host, &mut inference, round).await?;
2852                Err(error)
2853            }
2854            SessionInferenceWakeKind::RespondedStop { inference } => {
2855                drop(inference);
2856                self.finish_stepped_turn(turn, None, checkpoint).await
2857            }
2858            SessionInferenceWakeKind::Event {
2859                mut inference,
2860                event,
2861            } => {
2862                let event = match event {
2863                    Ok(Some(event)) => event,
2864                    Ok(None) => {
2865                        let mut host = KennedySessionHost {
2866                            session: self,
2867                            checkpoint,
2868                            state: &mut turn.state,
2869                        };
2870                        record_unavailable_inference(&mut host, &mut inference, round).await?;
2871                        anyhow::bail!("provider ended without a terminal turn event");
2872                    }
2873                    Err(error) => {
2874                        let mut host = KennedySessionHost {
2875                            session: self,
2876                            checkpoint,
2877                            state: &mut turn.state,
2878                        };
2879                        if let Some(receipt) = inference_error_receipt(&error) {
2880                            host.record(kcode_agent_runtime::SessionEvent::ProviderReceipt {
2881                                round,
2882                                usage: None,
2883                                receipt: Box::new(receipt),
2884                                continuation: None,
2885                            })
2886                            .await?;
2887                        }
2888                        return Err(error);
2889                    }
2890                };
2891
2892                match event {
2893                    kcode_agent_runtime::SessionInferenceEvent::ProviderInput { context } => {
2894                        let mut host = KennedySessionHost {
2895                            session: self,
2896                            checkpoint,
2897                            state: &mut turn.state,
2898                        };
2899                        host.record(kcode_agent_runtime::SessionEvent::ProviderInput {
2900                            round,
2901                            context,
2902                        })
2903                        .await?;
2904                        Ok(TurnBoundary::Await(PendingSessionInference {
2905                            turn,
2906                            action: Box::new(PendingInferenceAction::Next { inference }),
2907                        }))
2908                    }
2909                    kcode_agent_runtime::SessionInferenceEvent::UsageUpdated { usage } => {
2910                        let mut host = KennedySessionHost {
2911                            session: self,
2912                            checkpoint,
2913                            state: &mut turn.state,
2914                        };
2915                        host.record(kcode_agent_runtime::SessionEvent::UsageUpdated {
2916                            round,
2917                            usage,
2918                        })
2919                        .await?;
2920                        Ok(TurnBoundary::Await(PendingSessionInference {
2921                            turn,
2922                            action: Box::new(PendingInferenceAction::Next { inference }),
2923                        }))
2924                    }
2925                    kcode_agent_runtime::SessionInferenceEvent::ToolCall { call_id, call } => {
2926                        turn.state.used_tool = true;
2927                        let call =
2928                            call.map_err(|error| anyhow::anyhow!("Invalid Ktool call: {error}"));
2929                        let mut host = KennedySessionHost {
2930                            session: self,
2931                            checkpoint,
2932                            state: &mut turn.state,
2933                        };
2934                        let outcome = match host.execute_tool(call, operation_id).await {
2935                            Ok(outcome) => outcome,
2936                            Err(error) => {
2937                                record_unavailable_inference(&mut host, &mut inference, round)
2938                                    .await?;
2939                                return Err(error);
2940                            }
2941                        };
2942                        let resume = match host.prepare_provider_resume(outcome).await {
2943                            Ok(resume) => resume,
2944                            Err(error) => {
2945                                record_unavailable_inference(&mut host, &mut inference, round)
2946                                    .await?;
2947                                return Err(error);
2948                            }
2949                        };
2950                        match resume {
2951                            kcode_agent_runtime::ProviderResume::Continue(mut outcome) => {
2952                                host.state.finish_requested |=
2953                                    outcome.ok && outcome.finish_after_round;
2954                                host.state.emitted_response |=
2955                                    outcome.ok && outcome.emitted_response;
2956                                host.state.pending_capture = outcome.capture.take();
2957                                let stop = outcome.stop;
2958                                let result = if outcome.ok {
2959                                    kcode_codex_runtime_v2::ToolResult::success(outcome.text)
2960                                } else {
2961                                    kcode_codex_runtime_v2::ToolResult::failure(outcome.text)
2962                                };
2963                                Ok(TurnBoundary::Await(PendingSessionInference {
2964                                    turn,
2965                                    action: Box::new(PendingInferenceAction::Respond {
2966                                        inference,
2967                                        call_id,
2968                                        result,
2969                                        stop,
2970                                    }),
2971                                }))
2972                            }
2973                            kcode_agent_runtime::ProviderResume::Complete(answer) => {
2974                                record_unavailable_inference(&mut host, &mut inference, round)
2975                                    .await?;
2976                                self.finish_stepped_turn(turn, answer, checkpoint).await
2977                            }
2978                            kcode_agent_runtime::ProviderResume::RestartFresh => {
2979                                record_unavailable_inference(&mut host, &mut inference, round)
2980                                    .await?;
2981                                turn.at_yielded_boundary = true;
2982                                Ok(TurnBoundary::Yield(turn))
2983                            }
2984                        }
2985                    }
2986                    kcode_agent_runtime::SessionInferenceEvent::Completed {
2987                        answer,
2988                        usage,
2989                        receipt,
2990                        continuation,
2991                    } => {
2992                        drop(inference);
2993                        let mut host = KennedySessionHost {
2994                            session: self,
2995                            checkpoint,
2996                            state: &mut turn.state,
2997                        };
2998                        host.record(kcode_agent_runtime::SessionEvent::ProviderReceipt {
2999                            round,
3000                            usage,
3001                            receipt,
3002                            continuation,
3003                        })
3004                        .await?;
3005                        let capture = host.state.pending_capture.take();
3006                        let control = if let Some(capture) = capture {
3007                            host.complete_capture(capture, answer).await?
3008                        } else {
3009                            let completion = kcode_agent_runtime::RoundCompletion {
3010                                answer,
3011                                used_tool: host.state.used_tool,
3012                                finish_requested: host.state.finish_requested,
3013                                emitted_response: host.state.emitted_response,
3014                            };
3015                            host.complete_round(completion).await?
3016                        };
3017                        host.state.used_tool = false;
3018                        host.state.finish_requested = false;
3019                        host.state.emitted_response = false;
3020                        match control {
3021                            kcode_agent_runtime::SessionControl::Continue => {
3022                                turn.at_yielded_boundary = true;
3023                                Ok(TurnBoundary::Yield(turn))
3024                            }
3025                            kcode_agent_runtime::SessionControl::Complete(answer) => {
3026                                self.finish_stepped_turn(turn, answer, checkpoint).await
3027                            }
3028                        }
3029                    }
3030                }
3031            }
3032        }
3033    }
3034
3035    pub async fn run_pending_turn<C, F>(
3036        &mut self,
3037        operation_id: Uuid,
3038        turn_deadline: Option<TurnDeadline>,
3039        mut checkpoint: C,
3040    ) -> anyhow::Result<Option<String>>
3041    where
3042        C: FnMut(Value) -> F + Send,
3043        F: Future<Output = anyhow::Result<()>> + Send,
3044    {
3045        let Some(turn) = self.begin_pending_turn(operation_id, turn_deadline)? else {
3046            return Ok(None);
3047        };
3048        let mut boundary = self.advance_pending_turn(turn, &mut checkpoint).await?;
3049        loop {
3050            boundary = match boundary {
3051                TurnBoundary::Await(pending) => {
3052                    let wake = pending.wait().await;
3053                    self.apply_inference_wake(wake, &mut checkpoint).await?
3054                }
3055                TurnBoundary::Yield(turn) => {
3056                    self.advance_pending_turn(turn, &mut checkpoint).await?
3057                }
3058                TurnBoundary::Complete(answer) => return Ok(answer),
3059            };
3060        }
3061    }
3062
3063    fn project_descendant<T>(
3064        &mut self,
3065        outcome: Result<kcode_intelligence_router::Accounted<T>, services::ApiError>,
3066    ) -> anyhow::Result<T> {
3067        match outcome {
3068            Ok(accounted) => {
3069                kcode_intelligence_chatend::record_descendant_receipt(
3070                    &mut self.journal,
3071                    &accounted.receipt,
3072                )?;
3073                Ok(accounted.value)
3074            }
3075            Err(error) => {
3076                if let Some(receipt) = &error.receipt {
3077                    kcode_intelligence_chatend::record_descendant_receipt(
3078                        &mut self.journal,
3079                        receipt,
3080                    )?;
3081                }
3082                Err(error.into())
3083            }
3084        }
3085    }
3086
3087    async fn run_subagent(
3088        &mut self,
3089        model: String,
3090        reasoning_effort: Option<String>,
3091        context_node_ids: Vec<String>,
3092        task: String,
3093        parent_operation_id: Uuid,
3094    ) -> anyhow::Result<String> {
3095        let reasoning_effort =
3096            reasoning_effort.unwrap_or_else(|| self.runtime.reasoning_effort.clone());
3097        let mut selected_node_descriptions = Vec::with_capacity(context_node_ids.len());
3098        for node_id in &context_node_ids {
3099            selected_node_descriptions.push(self.api.kmap_node(node_id)?.data.long_description);
3100        }
3101        let user_id = self
3102            .root_node_ids
3103            .first()
3104            .context("session has no user root for subagent intelligence accounting")?
3105            .clone();
3106        let timeout = self.agent_request_timeout();
3107        let runtime = self.api.agent_runtime();
3108        let provider = runtime.resolve_model(&model).await?.provider;
3109        let first_event = self.journal.state().events.len();
3110        let cost_before = self.projection().status;
3111        let subagent_context = SubagentContext::new(
3112            self.root_node_ids.clone(),
3113            self.api.loads_fixed_connections(),
3114            provider,
3115            self.subagent_codex_prompt.clone(),
3116            selected_node_descriptions,
3117        )?;
3118        let initial_sections = subagent_context.initial_sections().to_vec();
3119        let result = {
3120            let mut host = KennedySubagentHost {
3121                session: self,
3122                context: subagent_context,
3123                captures: HashMap::new(),
3124            };
3125            runtime
3126                .run(
3127                    kcode_agent_runtime::RunRequest {
3128                        user_id,
3129                        parent_operation_id,
3130                        model,
3131                        reasoning_effort,
3132                        context: initial_sections,
3133                        task,
3134                        timeout,
3135                        start_metadata: json!({"contextNodeIds":context_node_ids}),
3136                    },
3137                    &mut host,
3138                )
3139                .await
3140        };
3141        match result {
3142            Ok(result) => {
3143                let cost_after = self.projection().status;
3144                Ok(format!(
3145                    "{}\n\n[{}]",
3146                    result.answer,
3147                    cost_summary(
3148                        "subagent cost",
3149                        cost_after
3150                            .estimated_cost_usd_nanos
3151                            .saturating_sub(cost_before.estimated_cost_usd_nanos),
3152                        cost_after
3153                            .unpriced_provider_calls
3154                            .saturating_sub(cost_before.unpriced_provider_calls),
3155                    )
3156                ))
3157            }
3158            Err(error) => {
3159                let may_have_effects =
3160                    self.journal.state().events[first_event..]
3161                        .iter()
3162                        .any(|event| {
3163                            matches!(
3164                                &event.kind,
3165                                EventKind::Note { label, .. } if label == "subagent_tool_call"
3166                            )
3167                        });
3168                if may_have_effects {
3169                    Err(error.context(
3170                        "the subagent failed after making Ktool calls; some tool effects may already have occurred",
3171                    ))
3172                } else {
3173                    Err(error)
3174                }
3175            }
3176        }
3177    }
3178
3179    async fn complete_subagent_freeform_write(
3180        &mut self,
3181        context: &mut SubagentContext,
3182        request: FreeformWrite,
3183        contents: String,
3184        budget: &kcode_agent_runtime::ContextBudget,
3185    ) -> anyhow::Result<kcode_agent_runtime::ToolOutcome> {
3186        let kind = request.kind();
3187        let freeform_tool = request.write_tool();
3188        anyhow::ensure!(
3189            context.source_is_open(kind, request.name()),
3190            "{} {:?} is not open in this subagent context. Call {} first.",
3191            kind.label(),
3192            request.name(),
3193            kind.open_tool()
3194        );
3195        let backend_arguments = request.capture_subagent(&mut self.journal, &now(), contents)?;
3196        let preview = self
3197            .api
3198            .managed_source_execute(
3199                &self.rust_lib_session_id,
3200                request.preview_tool(),
3201                backend_arguments.clone(),
3202                Vec::new(),
3203            )
3204            .await?;
3205        let preview = preview
3206            .snapshot
3207            .context("subagent freeform write preview omitted its source snapshot")?;
3208        let preview_state = context.source_state(&preview);
3209        anyhow::ensure!(
3210            budget.fits_state(preview_state.key, preview_state.text),
3211            "{freeform_tool} was not run because its resulting source state would exceed the subagent context limit"
3212        );
3213        let execution = self
3214            .api
3215            .managed_source_execute(
3216                &self.rust_lib_session_id,
3217                freeform_tool,
3218                backend_arguments,
3219                Vec::new(),
3220            )
3221            .await?;
3222        let snapshot = execution
3223            .snapshot
3224            .context("subagent freeform write omitted its resulting source snapshot")?;
3225        let state = context.apply_source_snapshot(snapshot);
3226        Ok(kcode_agent_runtime::ToolOutcome {
3227            text: execution.text,
3228            ok: true,
3229            state_updates: state.update.into_iter().collect(),
3230            displayed_state_keys: Vec::new(),
3231            capture: None,
3232        })
3233    }
3234
3235    async fn complete_freeform_write(
3236        &mut self,
3237        pending: PendingFreeformWrite,
3238        contents: String,
3239    ) -> anyhow::Result<ToolOutcome> {
3240        let request = pending.request;
3241        let freeform_tool = request.write_tool();
3242        let backend_arguments =
3243            request.capture(&mut self.journal, &now(), pending.call_box_id, contents)?;
3244        let preview_result = self
3245            .api
3246            .managed_source_execute(
3247                &self.rust_lib_session_id,
3248                request.preview_tool(),
3249                backend_arguments.clone(),
3250                Vec::new(),
3251            )
3252            .await;
3253        let preview = match preview_result {
3254            Ok(preview) => preview,
3255            Err(error) => {
3256                return Ok(ToolOutcome {
3257                    text: format!("{freeform_tool} failed: {error}"),
3258                    store_result: true,
3259                    ok: false,
3260                    end_session: false,
3261                    freeform_write: None,
3262                    managed_source_snapshot: None,
3263                    exact_result: false,
3264                });
3265            }
3266        };
3267        let _preview = preview
3268            .snapshot
3269            .context("freeform write preview omitted the resulting source snapshot")?;
3270        request.source_box_id(&self.journal)?;
3271
3272        let execution_result = self
3273            .api
3274            .managed_source_execute(
3275                &self.rust_lib_session_id,
3276                freeform_tool,
3277                backend_arguments,
3278                Vec::new(),
3279            )
3280            .await;
3281        let execution = match execution_result {
3282            Ok(execution) => execution,
3283            Err(error) => {
3284                return Ok(ToolOutcome {
3285                    text: format!("{freeform_tool} failed: {error}"),
3286                    store_result: true,
3287                    ok: false,
3288                    end_session: false,
3289                    freeform_write: None,
3290                    managed_source_snapshot: None,
3291                    exact_result: false,
3292                });
3293            }
3294        };
3295        let snapshot = execution
3296            .snapshot
3297            .context("freeform write omitted the resulting source snapshot")?;
3298        apply_snapshot(&mut self.journal, &now(), snapshot)?;
3299        Ok(ToolOutcome {
3300            text: execution.text,
3301            store_result: false,
3302            ok: true,
3303            end_session: false,
3304            freeform_write: None,
3305            managed_source_snapshot: None,
3306            exact_result: false,
3307        })
3308    }
3309
3310    async fn send_telegram_dm(&mut self, arguments: &Value) -> anyhow::Result<String> {
3311        let request = kcode_telegram_session_coordinator::parse_private_request(arguments)?;
3312        let attachments = self.telegram_delivery_attachments(request.attachments)?;
3313        let caller_holds_user_lock = self.session_type == "telegram"
3314            && self.channel.get("telegramUserId").and_then(Value::as_i64)
3315                == Some(request.telegram_user_id);
3316        self.api
3317            .telegram()
3318            .send_private(kcode_telegram_session_coordinator::PrivateDelivery {
3319                telegram_user_id: request.telegram_user_id,
3320                message: request.message,
3321                attachments,
3322                caller_holds_user_lock,
3323            })
3324            .await
3325    }
3326
3327    async fn send_telegram_group_message(&mut self, arguments: &Value) -> anyhow::Result<String> {
3328        let request = kcode_telegram_session_coordinator::parse_group_request(arguments)?;
3329        let attachments = self.telegram_delivery_attachments(request.attachments)?;
3330        self.api
3331            .telegram()
3332            .send_group(kcode_telegram_session_coordinator::GroupDelivery {
3333                root_node_id: request.root_node_id,
3334                message: request.message,
3335                attachments,
3336            })
3337            .await
3338    }
3339
3340    fn telegram_delivery_attachments(
3341        &mut self,
3342        requests: Vec<kcode_telegram_session_coordinator::AttachmentRequest>,
3343    ) -> anyhow::Result<Vec<kcode_telegram_session_coordinator::Attachment>> {
3344        let api = self.api.clone();
3345        kcode_kennedy_session_objects::delivery_attachments(
3346            &mut self.journal,
3347            requests,
3348            move |canonical_id| api.kmap_file(canonical_id).map_err(Into::into),
3349        )
3350    }
3351
3352    async fn execute_tool(
3353        &mut self,
3354        call: &ToolCall,
3355        operation_id: Uuid,
3356    ) -> anyhow::Result<ToolOutcome> {
3357        self.assert_tool_allowed(&call.name)?;
3358        anyhow::ensure!(
3359            call.name != LAUNCH_SESSION_TOOL,
3360            "LaunchSession requires the checkpointed launch dispatch lane"
3361        );
3362        let decoded = decode(&call.name, &call.arguments)?;
3363        let mut end_session = false;
3364        let mut store_result = true;
3365        let mut freeform_write = None;
3366        let mut managed_source_snapshot = None;
3367        let text = match (call.name.as_str(), decoded) {
3368            ("NoteToSelf", None) => {
3369                decode_note_to_self(&call.arguments)?;
3370                store_result = false;
3371                "Note saved.".into()
3372            }
3373            ("SendTelegramDM", _) => self.send_telegram_dm(&call.arguments).await?,
3374            ("SendTelegramGroupMessage", _) => {
3375                self.send_telegram_group_message(&call.arguments).await?
3376            }
3377            (
3378                "RunSubagent",
3379                Some(DecodedTool::RunSubagent {
3380                    model,
3381                    reasoning_effort,
3382                    context_node_ids,
3383                    task,
3384                }),
3385            ) => {
3386                let first_event = self.journal.state().events.len();
3387                match self
3388                    .run_subagent(
3389                        model,
3390                        reasoning_effort,
3391                        context_node_ids,
3392                        task,
3393                        operation_id,
3394                    )
3395                    .await
3396                {
3397                    Ok(response) => response,
3398                    Err(error) => {
3399                        let may_have_effects = self.journal.state().events[first_event..]
3400                            .iter()
3401                            .any(|event| {
3402                                matches!(
3403                                    &event.kind,
3404                                    EventKind::Note { label, .. }
3405                                        if label == "subagent_tool_call"
3406                                )
3407                            });
3408                        if may_have_effects {
3409                            return Err(error.context(
3410                                "the subagent failed after making Ktool calls; some tool effects may already have occurred",
3411                            ));
3412                        }
3413                        return Err(error);
3414                    }
3415                }
3416            }
3417            ("EndSession", Some(DecodedTool::EndSession { message })) => {
3418                anyhow::ensure!(
3419                    !matches!(self.mode, AgentMode::Conversation),
3420                    "EndSession is only available during an autonomous or history-ingress session"
3421                );
3422                end_session = true;
3423                if matches!(self.mode, AgentMode::FreeTime)
3424                    && let Some(message) = message.filter(|message| !message.trim().is_empty())
3425                {
3426                    self.free_time["nextSessionMessage"] = json!(message);
3427                }
3428                "Session ending.".into()
3429            }
3430            ("DehydrateBoxes", Some(DecodedTool::BoxIds(ids))) => {
3431                self.journal.dehydrate_boxes(now(), &ids)?;
3432                format!(
3433                    "Dehydrated boxes {}.",
3434                    ids.iter()
3435                        .map(ToString::to_string)
3436                        .collect::<Vec<_>>()
3437                        .join(", ")
3438                )
3439            }
3440            ("SummarizeBox", Some(DecodedTool::SummarizeBox { box_id, summary })) => {
3441                self.journal.summarize_box(now(), box_id, summary)?;
3442                format!("Summarized box {box_id}.")
3443            }
3444            ("HydrateBox", Some(DecodedTool::BoxId(id))) => {
3445                self.journal.rehydrate_box(now(), id)?;
3446                let external_event_id = self.pending_external_event_id.clone();
3447                match self.recover_context_overflow(external_event_id.as_deref(), &[id])? {
3448                    ContextRecovery::NotNeeded => format!("Hydrated box {id}."),
3449                    ContextRecovery::Recovered => {
3450                        format!("Hydrated box {id}.\n\n{CONTEXT_OVERFLOW_WARNING}")
3451                    }
3452                    ContextRecovery::Irreducible => anyhow::bail!(CONTEXT_OVERFLOW_WARNING),
3453                }
3454            }
3455            ("BoxesIntoObjects", Some(DecodedTool::BoxIds(ids))) => {
3456                kcode_kennedy_box_text_objects::stage_box_text_objects(
3457                    &mut self.journal,
3458                    &ids,
3459                    &now(),
3460                )?
3461            }
3462            ("LoadNodes", Some(DecodedTool::LoadNodes(identifiers))) => {
3463                load_durable_batch(self.api.kmap(), &mut self.context, &identifiers)?;
3464                let changed = self.sync_load_kweb_boxes()?;
3465                store_result = false;
3466                render_load_nodes_result(
3467                    &self.journal,
3468                    &changed,
3469                    &self.runtime_budget().footer_lines(),
3470                )?
3471            }
3472            (
3473                "EmitObject",
3474                Some(DecodedTool::EmitObject {
3475                    object_id,
3476                    file_name,
3477                }),
3478            ) => {
3479                anyhow::ensure!(
3480                    matches!(self.mode, AgentMode::Conversation),
3481                    "EmitObject is only available in a conversation"
3482                );
3483                let object = self.resolve_object(&object_id)?;
3484                let file_name = file_name.unwrap_or_else(|| object.file_name.clone());
3485                if let Some(maximum) = self.channel.get("maxObjectBytes").and_then(Value::as_u64) {
3486                    anyhow::ensure!(
3487                        !object.bytes.is_empty(),
3488                        "object {object_id} is empty and cannot be sent through this channel"
3489                    );
3490                    anyhow::ensure!(
3491                        object.bytes.len() as u64 <= maximum,
3492                        "object {object_id} is {} bytes, over this channel's {maximum}-byte limit",
3493                        object.bytes.len()
3494                    );
3495                }
3496                let descriptor = json!({
3497                    "objectId":object_id,
3498                    "fileName":file_name,
3499                    "mediaType":object.media_type,
3500                    "byteLength":object.bytes.len(),
3501                });
3502                let mut metadata = json!({
3503                    "outputKind":"object",
3504                    "attachments":[descriptor.clone()],
3505                });
3506                if let Some(external_event_id) = &self.pending_external_event_id {
3507                    metadata["externalEventId"] = json!(external_event_id);
3508                }
3509                let content = BoxContent {
3510                    text: String::new(),
3511                    objects: vec![object_id.clone()],
3512                    metadata,
3513                };
3514                self.journal
3515                    .create_box(now(), "Kennedy message", BoxOwner::Kennedy, content)?;
3516                let mut transcript = json!({
3517                    "role":"kennedy",
3518                    "content":"",
3519                    "objects":[object_id],
3520                    "attachments":[descriptor],
3521                });
3522                if let Some(external_event_id) = &self.pending_external_event_id {
3523                    transcript["externalEventId"] = json!(external_event_id);
3524                }
3525                self.transcript.push(transcript);
3526                store_result = false;
3527                "Object emitted to the user.".into()
3528            }
3529            ("WebSearch", Some(DecodedTool::WebSearch { question, model })) => {
3530                let user_id = self
3531                    .root_node_ids
3532                    .first()
3533                    .context("session has no user root for intelligence accounting")?
3534                    .clone();
3535                let outcome = self
3536                    .api
3537                    .search(
3538                        &user_id,
3539                        kcode_intelligence_router::SearchRequest {
3540                            question,
3541                            model,
3542                            operation_id: Uuid::new_v4(),
3543                            parent_operation_id: Some(operation_id),
3544                        },
3545                    )
3546                    .await;
3547                let result = self.project_descendant(outcome)?;
3548                render_web_search_result(&result)?
3549            }
3550            ("WebFetch", Some(DecodedTool::WebFetch(url))) => {
3551                let user_id = self
3552                    .root_node_ids
3553                    .first()
3554                    .context("session has no user root for intelligence accounting")?;
3555                let result = self
3556                    .api
3557                    .fetch(
3558                        user_id,
3559                        kcode_intelligence_router::FetchRequest {
3560                            url,
3561                            operation_id: Uuid::new_v4(),
3562                            parent_operation_id: Some(operation_id),
3563                        },
3564                    )
3565                    .await?;
3566                render_web_fetch_result(&result)?
3567            }
3568            ("StageTelegramGroupMedia", Some(DecodedTool::StageTelegramGroupMedia(message_id))) => {
3569                let media_ref = kcode_telegram_session_coordinator::group_media_reference(
3570                    &self.group_context,
3571                    message_id,
3572                )?;
3573                let chat_id = media_ref.chat_id;
3574                let api = self.api.clone();
3575                let staged = kcode_kennedy_session_objects::stage_telegram_group_media(
3576                    &mut self.journal,
3577                    kcode_kennedy_session_objects::TelegramStageRequest {
3578                        chat_id,
3579                        message_id,
3580                        maximum_bytes: MAX_MEDIA_ENRICHMENT_BYTES,
3581                        transport_metadata: media_ref.transport_metadata(),
3582                        recorded_at: now(),
3583                    },
3584                    || api.telegram().group_message_media(chat_id, message_id),
3585                    |media_type| {
3586                        kcode_telegram_session_coordinator::group_media_file_name(
3587                            &media_ref, media_type,
3588                        )
3589                    },
3590                )?;
3591                render(RenderRequest::StagedTelegramMedia {
3592                    pending_id: &staged.descriptor.pending_id,
3593                    kind: &staged.kind,
3594                    file_name: &staged.descriptor.file_name,
3595                    media_type: &staged.descriptor.media_type,
3596                    size_bytes: staged.descriptor.size_bytes,
3597                    message_id,
3598                    reused: staged.reused,
3599                })?
3600            }
3601            (
3602                "TranscribeAudio",
3603                Some(DecodedTool::MediaEnrichment {
3604                    object_id,
3605                    model,
3606                    prompt,
3607                }),
3608            ) => {
3609                let object = self.resolve_media_object(&object_id)?;
3610                validate(ValidationRequest::TranscribableAudio(&object.media_type))?;
3611                validate(ValidationRequest::TranscriptionModel(&model))?;
3612                let user_id = self
3613                    .root_node_ids
3614                    .first()
3615                    .context("session has no user root for intelligence accounting")?
3616                    .clone();
3617                let outcome = self
3618                    .api
3619                    .transcribe_audio(
3620                        &user_id,
3621                        &model,
3622                        &prompt,
3623                        object.bytes,
3624                        object.file_name.clone(),
3625                        &object.media_type,
3626                        None,
3627                        operation_id,
3628                    )
3629                    .await;
3630                let result = self.project_descendant(outcome)?;
3631                render_audio_transcription_result(
3632                    &object.object_id,
3633                    &object.file_name,
3634                    &object.media_type,
3635                    &result,
3636                )?
3637            }
3638            (
3639                "AnnotateMedia",
3640                Some(DecodedTool::MediaEnrichment {
3641                    object_id,
3642                    model,
3643                    prompt,
3644                }),
3645            ) => {
3646                let media = self.resolve_media_object(&object_id)?;
3647                validate(ValidationRequest::Annotation {
3648                    model: &model,
3649                    media_type: &media.media_type,
3650                })?;
3651                let user_id = self
3652                    .root_node_ids
3653                    .first()
3654                    .context("session has no user root for intelligence accounting")?
3655                    .clone();
3656                let outcome = self
3657                    .api
3658                    .annotate_media(
3659                        &user_id,
3660                        &model,
3661                        &prompt,
3662                        media.bytes,
3663                        media.file_name.clone(),
3664                        &media.media_type,
3665                        operation_id,
3666                    )
3667                    .await;
3668                let result = self.project_descendant(outcome)?;
3669                render_media_annotation_result(
3670                    &media.object_id,
3671                    &media.file_name,
3672                    &media.media_type,
3673                    &result,
3674                )?
3675            }
3676            (
3677                "GenerateImage",
3678                Some(DecodedTool::GenerateImage {
3679                    model,
3680                    prompt,
3681                    reference_object_ids,
3682                }),
3683            ) => {
3684                let mut references = Vec::with_capacity(reference_object_ids.len());
3685                for object_id in &reference_object_ids {
3686                    references.push(self.resolve_image_object(object_id)?);
3687                }
3688                let user_id = self
3689                    .root_node_ids
3690                    .first()
3691                    .context("session has no user root for intelligence accounting")?
3692                    .clone();
3693                let outcome = self
3694                    .api
3695                    .generate_image(&user_id, &model, &prompt, references, operation_id)
3696                    .await;
3697                let result = self.project_descendant(outcome)?;
3698                let size = result.bytes.len();
3699                let file_name =
3700                    format!("generated-image.{}", image_extension(&result.content_type));
3701                let object_id = self.api.save_generated_image(
3702                    result.bytes,
3703                    &file_name,
3704                    &result.content_type,
3705                    &result.model,
3706                )?;
3707                format!(
3708                    "Generated image.\nObject: {object_id}\nFile: {file_name}\nContent type: {}\nSize: {size} bytes\nModel: {}\nUse EmitObject with {object_id} to deliver it.",
3709                    result.content_type, result.model
3710                )
3711            }
3712            ("ExtractDocumentText", Some(DecodedTool::ObjectId(object_id))) => {
3713                let object = self.resolve_media_object(&object_id)?;
3714                validate(ValidationRequest::ExtractableDocument {
3715                    media_type: &object.media_type,
3716                    file_name: &object.file_name,
3717                })?;
3718                let result = self
3719                    .api
3720                    .extract_document(object.bytes, object.file_name.clone(), &object.media_type)
3721                    .await?;
3722                render_document_extraction_result(&object.object_id, &object.file_name, &result)?
3723            }
3724            (name, None) if SPEECH_CLASSIFICATION_TOOLS.contains(&name) => {
3725                self.api
3726                    .execute_speech_classification_tool(name, call.arguments.clone())
3727                    .await?
3728            }
3729            (name, None) if TASK_BOARD_TOOLS.contains(&name) => {
3730                self.execute_task_board_tool(name, &call.arguments).await?
3731            }
3732            (name, Some(decoded)) if is_kweb_mutation(name) => {
3733                let (text, _) = execute_kweb_mutation(
3734                    name,
3735                    decoded,
3736                    &self.context,
3737                    &mut self.plan,
3738                    &mut self.journal,
3739                )?;
3740                self.sync_kweb_boxes()?;
3741                text
3742            }
3743            (name, None)
3744                if RUST_LIB_TOOLS.contains(&name)
3745                    || WEB_LIB_TOOLS.contains(&name)
3746                    || RUST_BIN_TOOLS.contains(&name) =>
3747            {
3748                if let Some(request) = prepare_freeform_write(&self.journal, name, &call.arguments)?
3749                {
3750                    store_result = false;
3751                    let acknowledgement = request.acknowledgement();
3752                    freeform_write = Some(request);
3753                    acknowledgement
3754                } else {
3755                    let object_ids = if name == CALL_RUST_BIN_TOOL {
3756                        decode_managed_objects(ManagedObjectArguments::RustBinary(&call.arguments))?
3757                    } else if name == ATTACH_OBJECT_WEB_LIB_TOOL {
3758                        decode_managed_objects(ManagedObjectArguments::WebLibraryAttachment(
3759                            &call.arguments,
3760                        ))?
3761                    } else {
3762                        Vec::new()
3763                    };
3764                    let mut objects = Vec::with_capacity(object_ids.len());
3765                    for object_id in object_ids {
3766                        objects.push(self.resolve_object(&object_id)?.bytes);
3767                    }
3768                    let execution = self
3769                        .api
3770                        .managed_source_execute(
3771                            &self.rust_lib_session_id,
3772                            name,
3773                            call.arguments.clone(),
3774                            objects,
3775                        )
3776                        .await?;
3777                    if let Some(snapshot) = execution.snapshot {
3778                        managed_source_snapshot = Some(snapshot);
3779                        store_result = false;
3780                    }
3781                    execution.text
3782                }
3783            }
3784            (name, Some(_)) => {
3785                anyhow::bail!("decoded contract for {name} did not match its dispatch lane")
3786            }
3787            (name, None) => anyhow::bail!("Tool {name} is not available"),
3788        };
3789        Ok(ToolOutcome {
3790            text,
3791            store_result,
3792            ok: true,
3793            end_session,
3794            freeform_write,
3795            managed_source_snapshot,
3796            exact_result: false,
3797        })
3798    }
3799
3800    async fn execute_task_board_tool(
3801        &self,
3802        name: &str,
3803        arguments: &Value,
3804    ) -> anyhow::Result<String> {
3805        let board = self
3806            .api
3807            .task_board()
3808            .context("task board is not configured")?
3809            .clone();
3810        let name = name.to_owned();
3811        let arguments = arguments.clone();
3812        let user_id = self
3813            .root_node_ids
3814            .first()
3815            .context("session has no user root for task-category lookup")?
3816            .clone();
3817        tokio::task::spawn_blocking(move || -> anyhow::Result<String> {
3818            let output = match name.as_str() {
3819                "CreateTaskCategory" => serde_json::to_string_pretty(
3820                    &board.create_category(serde_json::from_value(arguments)?)?,
3821                )?,
3822                "GetTaskCategory" => {
3823                    let call: CategoryCall = serde_json::from_value(arguments)?;
3824                    serde_json::to_string_pretty(&board.category(
3825                        &call.category_id,
3826                        kcode_task_board::BrowsePage {
3827                            user_id,
3828                            offset: call.offset,
3829                            limit: call.limit,
3830                        },
3831                    )?)?
3832                }
3833                "RemoveTaskCategory" => {
3834                    let call: CategoryId = serde_json::from_value(arguments)?;
3835                    board.remove_category(&call.category_id)?;
3836                    format!("Removed category {}.", call.category_id)
3837                }
3838                "CreateTask" => serde_json::to_string_pretty(
3839                    &board.create_task(serde_json::from_value(arguments)?)?,
3840                )?,
3841                "GetTask" => {
3842                    let call: TaskId = serde_json::from_value(arguments)?;
3843                    serde_json::to_string_pretty(&board.task(&call.task_id)?)?
3844                }
3845                "UpdateTask" => serde_json::to_string_pretty(
3846                    &board.update_task(serde_json::from_value(arguments)?)?,
3847                )?,
3848                "RemoveTask" => {
3849                    let call: TaskId = serde_json::from_value(arguments)?;
3850                    board.remove_task(&call.task_id)?;
3851                    format!("Removed task {}.", call.task_id)
3852                }
3853                "GetTopTaskOrphan" => {
3854                    let _: EmptyCall = serde_json::from_value(arguments)?;
3855                    serde_json::to_string_pretty(&board.top_orphan()?)?
3856                }
3857                _ => anyhow::bail!("Tool {name} is not a task-board operation"),
3858            };
3859            Ok(output)
3860        })
3861        .await
3862        .context("task-board worker stopped")?
3863    }
3864
3865    fn assert_tool_allowed(&self, name: &str) -> anyhow::Result<()> {
3866        let write = matches!(
3867            name,
3868            "ConnectNodes"
3869                | "ConsolidateFanout"
3870                | "SetFixedConnection"
3871                | "CreateNode"
3872                | "UpdateNode"
3873        );
3874        anyhow::ensure!(
3875            !write || !matches!(self.mode, AgentMode::Conversation),
3876            "{name} requires the global Kweb write lane and is unavailable in a read-only conversation"
3877        );
3878        if name == "EndSession" {
3879            anyhow::ensure!(
3880                !matches!(self.mode, AgentMode::Conversation),
3881                "EndSession is unavailable in a conversation"
3882            );
3883        }
3884        if name == LAUNCH_SESSION_TOOL {
3885            anyhow::ensure!(
3886                self.launch_session_authorized(),
3887                "LaunchSession is unavailable without a genuine current user turn in an eligible conversation"
3888            );
3889        }
3890        Ok(())
3891    }
3892
3893    fn sync_kweb_boxes(&mut self) -> anyhow::Result<Vec<BoxId>> {
3894        let (updates, creates) = self.plan.context_projection();
3895        self.context
3896            .sync_chatend(&mut self.journal, now(), &updates, &creates)
3897            .map_err(anyhow::Error::new)
3898    }
3899
3900    fn sync_load_kweb_boxes(&mut self) -> anyhow::Result<Vec<BoxId>> {
3901        let before = kweb_slot_box_ids(&self.journal);
3902        let (updates, creates) = self.plan.context_projection();
3903        let stale = self
3904            .context
3905            .sync_load_chatend(&mut self.journal, now(), &updates, &creates)
3906            .map_err(anyhow::Error::new)?;
3907        let after = kweb_slot_box_ids(&self.journal);
3908        Ok(load_box_changes(&before, &after, &stale))
3909    }
3910
3911    fn record_tool_invocation(
3912        &mut self,
3913        name: &str,
3914        arguments: Value,
3915    ) -> anyhow::Result<RecordedToolInvocation> {
3916        let invocation_id = Uuid::new_v4().to_string();
3917        let invocation = RecordedToolInvocation {
3918            tool_instance: tool_instance_for_invocation(name, &invocation_id),
3919            invocation_id,
3920            tool_name: name.into(),
3921        };
3922        self.journal.record(
3923            now(),
3924            EventKind::ToolInvoked {
3925                tool_instance: invocation.tool_instance.clone(),
3926                tool_name: invocation.tool_name.clone(),
3927                arguments,
3928                invocation_id: Some(invocation.invocation_id.clone()),
3929            },
3930        )?;
3931        Ok(invocation)
3932    }
3933
3934    fn record_tool_completion(
3935        &mut self,
3936        invocation: Option<&RecordedToolInvocation>,
3937        outcome: Value,
3938    ) -> anyhow::Result<EventId> {
3939        record_tool_completion_event(&mut self.journal, invocation, outcome)
3940    }
3941
3942    fn finalize_kweb_session(&mut self) -> anyhow::Result<()> {
3943        self.provider_affinity = None;
3944        self.next_thread_reset_reason = None;
3945        if self.commit_receipt.is_some() {
3946            return Ok(());
3947        }
3948        self.repair_unfinished_tools()?;
3949        self.journal.seal()?;
3950        let archive = self.journal.archive_bytes()?;
3951        let object_locations = self
3952            .journal
3953            .objects()
3954            .iter()
3955            .map(|(id, location)| (id.clone(), location.clone()))
3956            .collect::<Vec<_>>();
3957        let mut objects = BTreeMap::new();
3958        for (id, location) in object_locations {
3959            let pending_id = id.to_string();
3960            let transport_kind =
3961                kcode_kennedy_session_objects::staged_descriptor(&self.journal, &id)?
3962                    .transport_kind;
3963            let bytes = encode_file(
3964                &pending_id,
3965                location.metadata.file_name.as_deref(),
3966                &location.metadata.media_type,
3967                transport_kind.as_deref(),
3968                self.journal.read_object(&id)?,
3969            )
3970            .with_context(|| format!("encoding staged object {pending_id}"))?;
3971            anyhow::ensure!(
3972                objects.insert(pending_id.clone(), bytes).is_none(),
3973                "duplicate staged object {pending_id}"
3974            );
3975        }
3976        let material = self.plan.commit_material()?;
3977        let result = self.api.commit_kweb_session(CommitRequest {
3978            idempotency_key: self.journal.state().metadata.session_id.clone(),
3979            author: self.commit_author.clone(),
3980            source_created_at: DateTime::parse_from_rfc3339(&self.started_at)
3981                .context("session start timestamp is invalid")?
3982                .with_timezone(&Utc),
3983            archive,
3984            objects,
3985            creates: material.creates,
3986            updates: material.updates,
3987        })?;
3988        self.journal
3989            .mark_completed(result.session_object_id.to_string());
3990        self.commit_receipt = Some(result);
3991        Ok(())
3992    }
3993
3994    fn prepare_free_time_round(&mut self) -> anyhow::Result<bool> {
3995        if !matches!(self.mode, AgentMode::FreeTime) {
3996            return Ok(false);
3997        }
3998        let Some(deadline) = deadline(&self.free_time) else {
3999            return Ok(false);
4000        };
4001        if Utc::now() >= deadline {
4002            self.free_time_end_reason = Some("deadline".into());
4003            self.journal.create_box(
4004                now(),
4005                "Self-time timer",
4006                BoxOwner::Controller,
4007                BoxContent::text(
4008                    "The self-time deadline has arrived. Finish without starting more tool work.",
4009                ),
4010            )?;
4011            return Ok(true);
4012        }
4013        Ok(false)
4014    }
4015
4016    fn agent_request_timeout(&self) -> Option<Duration> {
4017        if matches!(self.mode, AgentMode::Conversation) && self.session_type == "conversation" {
4018            return Some(BROWSER_CONVERSATION_REQUEST_TIMEOUT);
4019        }
4020        if matches!(self.mode, AgentMode::Ingress { .. }) {
4021            return Some(HISTORY_INGRESS_REQUEST_TIMEOUT);
4022        }
4023        if matches!(self.mode, AgentMode::Wakeup) {
4024            return Some(WAKEUP_REQUEST_TIMEOUT);
4025        }
4026        if matches!(self.mode, AgentMode::FreeTime) {
4027            let deadline = deadline(&self.free_time)?;
4028            return Some(Duration::from_secs(
4029                (deadline - Utc::now()).num_seconds().max(1) as u64
4030                    + SELF_TIME_HARD_STOP_ALLOWANCE.as_secs(),
4031            ));
4032        }
4033        None
4034    }
4035
4036    pub fn refresh_telegram_group_context(
4037        &mut self,
4038        group_context: &Value,
4039        current_message_id: Option<&str>,
4040    ) -> anyhow::Result<()> {
4041        if self.session_type != "telegram-group" {
4042            return Ok(());
4043        }
4044        self.invalidate_active_stepped_turn();
4045        self.channel["groupContext"] = group_context.clone();
4046        self.group_context = group_context.clone();
4047        self.journal.create_box(
4048            now(),
4049            "Telegram group update",
4050            BoxOwner::Controller,
4051            BoxContent::text(kcode_telegram_session_coordinator::format_group_context(
4052                group_context,
4053            )),
4054        )?;
4055        self.recover_context_overflow(current_message_id, &[])?;
4056        Ok(())
4057    }
4058
4059    pub fn finalize_free_time(&mut self, reason: &str) -> anyhow::Result<()> {
4060        anyhow::ensure!(
4061            matches!(reason, "tool" | "deadline" | "hard-stop" | "user-stop"),
4062            "invalid self-time completion reason"
4063        );
4064        self.invalidate_active_stepped_turn();
4065        self.free_time["sliceEndedReason"] = json!(reason);
4066        self.free_time["sliceEndedAt"] = json!(now());
4067        self.pending_turn = false;
4068        self.pending_external_event_id = None;
4069        self.clear_launch_turn_authority();
4070        Ok(())
4071    }
4072
4073    pub fn commit_current_write_session(&mut self) -> anyhow::Result<()> {
4074        anyhow::ensure!(
4075            matches!(
4076                self.mode,
4077                AgentMode::FreeTime | AgentMode::Wakeup | AgentMode::Ingress { .. }
4078            ),
4079            "a read-only conversation cannot be committed as a Kweb write session"
4080        );
4081        self.invalidate_active_stepped_turn();
4082        self.finalize_kweb_session()?;
4083        self.completed = true;
4084        Ok(())
4085    }
4086
4087    pub fn snapshot(&self) -> anyhow::Result<Value> {
4088        let projection = self.projection();
4089        let submitted = self
4090            .journal
4091            .state()
4092            .current_ingress_attempt_events()
4093            .iter()
4094            .rev()
4095            .find_map(|event| {
4096                let EventKind::ProviderInputSubmitted { round, context, .. } = &event.kind else {
4097                    return None;
4098                };
4099                Some((event.recorded_at.as_str(), *round, context))
4100            });
4101        let (chatend_text, chatend_text_source, structured_material) = match submitted {
4102            Some((submitted_at, round, submitted)) => (
4103                submitted.input.clone(),
4104                "submitted",
4105                json!({
4106                    "provider":submitted.provider,
4107                    "model":submitted.model,
4108                    "reasoningEffort":submitted.reasoning_effort,
4109                    "baseInstructions":submitted.base_instructions,
4110                    "developerInstructions":submitted.developer_instructions,
4111                    "tools":submitted.tools,
4112                    "round":round,
4113                    "submittedAt":submitted_at,
4114                }),
4115            ),
4116            None => (projection.render(), "reconstructed", Value::Null),
4117        };
4118        let session_status = projection.status.clone();
4119        let completed_invocations = completed_invocation_ids(&self.journal);
4120        let launch_intents = pruned_launch_intents(
4121            &self.launch_intents,
4122            self.launch_user_turn_id,
4123            &completed_invocations,
4124        );
4125        Ok(json!({
4126            "format":"kennedy-chatend",
4127            "version":1,
4128            "stateVersion":CHECKPOINT_STATE_VERSION,
4129            "sessionId":self.journal.state().metadata.session_id,
4130            "chatendMetadata":self.journal.state().metadata,
4131            "sessionType":self.session_type,
4132            "sourceSessionType":self.source_session_type,
4133            "channel":self.channel,
4134            "freeTime":self.free_time,
4135            "orchestration":self.orchestration,
4136            "provenanceId":self.provenance_id,
4137            "launchProvenance":self.launch_provenance,
4138            "launchContextNodeIds":self.launch_context_node_ids,
4139            "launchUserTurnId":self.launch_user_turn_id,
4140            "launchIntents":launch_intents,
4141            "rustLibSessionId":self.rust_lib_session_id,
4142            "rootNodeIds":self.root_node_ids,
4143            "referenceRootNodeIds":self.reference_root_node_ids,
4144            "startedAt":self.started_at,
4145            "transcript":self.transcript,
4146            "pendingTurn":self.pending_turn,
4147            "pendingExternalEventId":self.pending_external_event_id,
4148            "roundsUsed":self.rounds_used,
4149            "providerAffinity":self.provider_affinity,
4150            "nextThreadResetReason":self.next_thread_reset_reason,
4151            "completed":self.completed,
4152            "sessionObjectId":self.journal.state().completed_session_object,
4153            "commitReceipt":self.commit_receipt,
4154            "commitAuthor":self.commit_author,
4155            "providerModel":self.runtime.model,
4156            "kwebPlan":self.plan.checkpoint_value()?,
4157            "boxCount":self.journal.state().boxes.len(),
4158            "eventCount":self.journal.state().events.len(),
4159            "boxes":self.journal.state().boxes,
4160            "events":self.journal.state().events,
4161            "context":projection,
4162            "sessionStatus":session_status,
4163            "chatendText":chatend_text,
4164            "chatendTextSource":chatend_text_source,
4165            "structuredMaterial":structured_material,
4166        }))
4167    }
4168
4169    pub async fn release_managed_sources(&self) {
4170        self.api
4171            .release_managed_sources(&self.rust_lib_session_id)
4172            .await;
4173    }
4174}
4175
4176impl<C, F> kcode_agent_runtime::SessionHost for KennedySessionHost<'_, C>
4177where
4178    C: FnMut(Value) -> F + Send,
4179    F: Future<Output = anyhow::Result<()>> + Send,
4180{
4181    fn prepare_round<'a>(
4182        &'a mut self,
4183        round: u64,
4184    ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::RoundPreparation> {
4185        Box::pin(async move {
4186            self.session.rounds_used = round;
4187            self.state.deadline_after_response = self.session.prepare_free_time_round()?;
4188            let external_event_id = self.session.pending_external_event_id.clone();
4189            if self
4190                .session
4191                .recover_context_overflow(external_event_id.as_deref(), &[])?
4192                == ContextRecovery::Irreducible
4193                || (matches!(self.session.mode, AgentMode::Ingress { .. })
4194                    && self.session.ingress_force_commit_requested())
4195            {
4196                return Ok(kcode_agent_runtime::RoundPreparation::Complete(None));
4197            }
4198            let ingress_time_remaining = self.session.ingress_time_remaining()?;
4199            let timeout = self.session.agent_request_timeout();
4200            self.session.begin_provider_call_budget(timeout);
4201            let tool_description = call_ktool_description(self.session.launch_session_authorized());
4202            let material_fingerprint = self
4203                .session
4204                .provider_material_fingerprint(&tool_description);
4205            let mut thread_reset_reason = self.session.next_thread_reset_reason.take();
4206            let mut continuation = None;
4207            let mut resume_after = None;
4208            if let Some(affinity) = &self.session.provider_affinity {
4209                if affinity.material_fingerprint == material_fingerprint {
4210                    continuation = Some(affinity.continuation.clone());
4211                    resume_after = Some(affinity.synchronized_event_id);
4212                } else {
4213                    self.session.provider_affinity = None;
4214                    thread_reset_reason = Some("provider_material_changed".into());
4215                }
4216            }
4217            if continuation.is_some() {
4218                self.session.provider_affinity = None;
4219                self.session.next_thread_reset_reason =
4220                    Some("prior_provider_turn_ambiguous".into());
4221            }
4222            let footer_lines = self.session.runtime_budget().footer_lines();
4223            let prepared = if let Some(remaining_seconds) = ingress_time_remaining {
4224                self.session
4225                    .journal
4226                    .prepare_provider_projection_with_ingress_time(
4227                        now(),
4228                        &footer_lines,
4229                        &material_fingerprint,
4230                        resume_after,
4231                        remaining_seconds,
4232                        self.session.previous_ingress_attempt_timed_out,
4233                    )?
4234            } else {
4235                self.session.journal.prepare_provider_projection(
4236                    now(),
4237                    &footer_lines,
4238                    &material_fingerprint,
4239                    resume_after,
4240                )?
4241            };
4242            if let Some(reason) = prepared.thread_reset_reason.clone() {
4243                self.session.provider_affinity = None;
4244                continuation = None;
4245                thread_reset_reason = Some(reason);
4246            }
4247            if continuation.is_none() && thread_reset_reason.is_some() {
4248                self.session.next_thread_reset_reason = thread_reset_reason.clone();
4249            }
4250            let input = prepared.projection.render();
4251            let projection_hash = hex::encode(Sha256::digest(input.as_bytes()));
4252            let provider_input_hash =
4253                hex::encode(Sha256::digest(prepared.provider_input.as_bytes()));
4254            let provider_input_bytes = prepared.provider_input.len() as u64;
4255            let thread_action = if continuation.is_some() {
4256                "resume"
4257            } else {
4258                "start"
4259            }
4260            .to_owned();
4261            self.state.prepared_cache = Some(PreparedCacheObservation {
4262                cacheable_prefix_bytes: prepared.cacheable_prefix_bytes,
4263                expectation: prepared.expectation,
4264                material_fingerprint,
4265                projection_hash,
4266                logical_input: input.clone(),
4267                provider_input_hash,
4268                provider_input_bytes,
4269                thread_action,
4270                thread_reset_reason,
4271                estimated_input_tokens: prepared.projection.estimated_tokens,
4272                raw_estimated_input_tokens: prepared.projection.raw_estimated_tokens,
4273                provider: String::new(),
4274                model: self.session.runtime.model.clone(),
4275            });
4276            Ok(kcode_agent_runtime::RoundPreparation::Run(
4277                kcode_agent_runtime::PreparedRound {
4278                    input,
4279                    provider_input: prepared.provider_input,
4280                    continuation,
4281                    model: self.session.runtime.model.clone(),
4282                    reasoning_effort: self.session.runtime.reasoning_effort.clone(),
4283                    tool_description,
4284                    timeout,
4285                },
4286            ))
4287        })
4288    }
4289
4290    fn record<'a>(
4291        &'a mut self,
4292        event: kcode_agent_runtime::SessionEvent,
4293    ) -> kcode_agent_runtime::HostFuture<'a, ()> {
4294        Box::pin(async move {
4295            match event {
4296                kcode_agent_runtime::SessionEvent::InferenceSubmitted {
4297                    manifest_hash,
4298                    model,
4299                    ..
4300                } => {
4301                    let prepared =
4302                        self.state.prepared_cache.as_ref().context(
4303                            "inference was submitted before provider context preparation",
4304                        )?;
4305                    anyhow::ensure!(
4306                        prepared.projection_hash == manifest_hash,
4307                        "provider input hash changed after context preparation"
4308                    );
4309                    self.state.accounting = Some(kcode_intelligence_chatend::TopLevelCall::new(
4310                        manifest_hash.clone(),
4311                        model,
4312                    ));
4313                    self.session.journal.record(
4314                        now(),
4315                        EventKind::InferenceSubmitted {
4316                            manifest_hash,
4317                            estimated_input_tokens: prepared.estimated_input_tokens,
4318                            raw_estimated_input_tokens: Some(prepared.raw_estimated_input_tokens),
4319                        },
4320                    )?;
4321                }
4322                kcode_agent_runtime::SessionEvent::ProviderInput { round, context } => {
4323                    let prepared = self
4324                        .state
4325                        .prepared_cache
4326                        .as_ref()
4327                        .context("provider context arrived before context preparation")?;
4328                    anyhow::ensure!(
4329                        hex::encode(Sha256::digest(context.input.as_bytes()))
4330                            == prepared.provider_input_hash,
4331                        "provider submitted transport input different from the prepared continuation delta"
4332                    );
4333                    let provider = context.provider.clone();
4334                    let model = context.model.clone();
4335                    let synchronized_after = self.session.journal.record(
4336                        now(),
4337                        EventKind::ProviderInputSubmitted {
4338                            round,
4339                            context: ProviderContext {
4340                                input: prepared.logical_input.clone(),
4341                                provider: context.provider,
4342                                model: context.model,
4343                                reasoning_effort: context.reasoning_effort,
4344                                base_instructions: context.base_instructions,
4345                                developer_instructions: context.developer_instructions,
4346                                tools: context
4347                                    .tools
4348                                    .into_iter()
4349                                    .map(|tool| ProviderToolDefinition {
4350                                        name: tool.name,
4351                                        description: tool.description,
4352                                        input_schema: tool.input_schema,
4353                                    })
4354                                    .collect(),
4355                            },
4356                            transport_input_hash: Some(prepared.provider_input_hash.clone()),
4357                            transport_input_bytes: Some(prepared.provider_input_bytes),
4358                            thread_action: Some(prepared.thread_action.clone()),
4359                            thread_reset_reason: prepared.thread_reset_reason.clone(),
4360                            cacheable_prefix_bytes: prepared.cacheable_prefix_bytes,
4361                            material_fingerprint: prepared.material_fingerprint.clone(),
4362                            cache_expectation: prepared.expectation.label().into(),
4363                            planned_invalidation_reason: prepared
4364                                .expectation
4365                                .planned_reason()
4366                                .map(str::to_owned),
4367                        },
4368                    )?;
4369                    self.state.provider_synchronized_after = Some(synchronized_after);
4370                    if let Some(prepared) = self.state.prepared_cache.as_mut() {
4371                        prepared.provider = provider;
4372                        prepared.model = model;
4373                    }
4374                }
4375                kcode_agent_runtime::SessionEvent::UsageUpdated { usage, .. } => {
4376                    self.state
4377                        .accounting
4378                        .as_mut()
4379                        .context("provider usage arrived before inference submission")?
4380                        .usage_updated(&mut self.session.journal, &now(), &usage)?;
4381                }
4382                kcode_agent_runtime::SessionEvent::ProviderReceipt {
4383                    usage,
4384                    receipt,
4385                    continuation,
4386                    ..
4387                } => {
4388                    self.state
4389                        .accounting
4390                        .take()
4391                        .context("provider receipt arrived before inference submission")?
4392                        .completed(&mut self.session.journal, &now(), usage.as_ref())?;
4393                    let prepared = self
4394                        .state
4395                        .prepared_cache
4396                        .take()
4397                        .context("provider receipt arrived before context preparation")?;
4398                    if let Some(reason) = self.state.restart_fresh_reason.take() {
4399                        anyhow::ensure!(
4400                            continuation.is_none(),
4401                            "restart-fresh receipt unexpectedly retained a native continuation"
4402                        );
4403                        self.session.provider_affinity = None;
4404                        self.session.next_thread_reset_reason = Some(reason);
4405                    } else if let Some(continuation) = continuation {
4406                        anyhow::ensure!(
4407                            receipt.provider_thread_id.as_deref()
4408                                == Some(continuation.thread_id.as_str()),
4409                            "provider receipt thread differs from continuation state"
4410                        );
4411                        let synchronized_event_id = self
4412                            .session
4413                            .journal
4414                            .state()
4415                            .events
4416                            .last()
4417                            .context("provider completion did not create a journal event")?
4418                            .id;
4419                        self.session.provider_affinity = Some(ProviderAffinityState {
4420                            continuation,
4421                            synchronized_event_id,
4422                            material_fingerprint: prepared.material_fingerprint.clone(),
4423                        });
4424                        self.session.next_thread_reset_reason = None;
4425                    } else {
4426                        self.session.provider_affinity = None;
4427                        self.session.next_thread_reset_reason = Some(
4428                            if prepared.thread_action == "resume" {
4429                                "provider_thread_resume_unavailable"
4430                            } else {
4431                                "provider_continuation_unavailable"
4432                            }
4433                            .into(),
4434                        );
4435                    }
4436                    log_primary_thread_observation(
4437                        self.state.operation_id,
4438                        self.session.rounds_used,
4439                        &self.session.runtime.model,
4440                        &prepared,
4441                        receipt.provider_thread_id.as_deref(),
4442                        usage.as_ref().map_or(0, |usage| usage.input_tokens),
4443                        usage.as_ref().map_or(0, |usage| usage.cached_input_tokens),
4444                    );
4445                }
4446            }
4447            let snapshot = self.session.snapshot()?;
4448            (self.checkpoint)(snapshot).await
4449        })
4450    }
4451
4452    fn execute_tool<'a>(
4453        &'a mut self,
4454        call: anyhow::Result<kcode_agent_runtime::ToolCall>,
4455        operation_id: Uuid,
4456    ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::SessionToolOutcome> {
4457        Box::pin(async move {
4458            if let Some(pending) = &self.state.pending_freeform_write {
4459                let text = format!(
4460                    "{} is awaiting the complete file contents; no other Ktool can run before that output.",
4461                    pending.request.write_tool()
4462                );
4463                self.session
4464                    .record_tool_completion(None, json!({"ok":false,"result":text}))?;
4465                return Ok(kcode_agent_runtime::SessionToolOutcome {
4466                    text,
4467                    ok: false,
4468                    capture: Some(json!(true)),
4469                    stop: false,
4470                    finish_after_round: false,
4471                    emitted_response: false,
4472                });
4473            }
4474            let tool_started_at = std::time::Instant::now();
4475            let mut created_call_box_id = None;
4476            let mut recorded_invocation = None;
4477            let transcript_start = self.session.transcript.len();
4478            let mut emitted_response = false;
4479            let mut outcome = match call {
4480                Ok(call) => {
4481                    let call = ToolCall {
4482                        name: call.name,
4483                        arguments: call.arguments,
4484                    };
4485                    let call_name = format!("Kennedy tool call: {}", call.name);
4486                    let call_content = tool_invocation_content(&call.name, &call.arguments)?;
4487                    recorded_invocation = Some(
4488                        self.session
4489                            .record_tool_invocation(&call.name, call.arguments.clone())?,
4490                    );
4491                    created_call_box_id = Some(self.session.journal.create_box(
4492                        now(),
4493                        call_name,
4494                        BoxOwner::Kennedy,
4495                        call_content,
4496                    )?);
4497                    let external_event_id = self.session.pending_external_event_id.clone();
4498                    if self
4499                        .session
4500                        .recover_context_overflow(external_event_id.as_deref(), &[])?
4501                        == ContextRecovery::Irreducible
4502                    {
4503                        ToolOutcome {
4504                            text: CONTEXT_OVERFLOW_WARNING.into(),
4505                            store_result: false,
4506                            ok: false,
4507                            end_session: false,
4508                            freeform_write: None,
4509                            managed_source_snapshot: None,
4510                            exact_result: false,
4511                        }
4512                    } else if call.name == LAUNCH_SESSION_TOOL {
4513                        let invocation = recorded_invocation
4514                            .as_ref()
4515                            .context("LaunchSession invocation was not recorded")?;
4516                        let result =
4517                            (|| -> anyhow::Result<(LaunchSessionArguments, LaunchIntent)> {
4518                                self.session.assert_tool_allowed(LAUNCH_SESSION_TOOL)?;
4519                                let arguments = decode_launch_session_arguments(&call.arguments)?;
4520                                let intent =
4521                                    self.session.prepare_launch_intent(invocation, &arguments)?;
4522                                Ok((arguments, intent))
4523                            })();
4524                        match result {
4525                            Ok((arguments, intent)) => {
4526                                (self.checkpoint)(self.session.snapshot()?).await?;
4527                                match self.session.lower_launch(&intent, &arguments).await {
4528                                    Ok(launch) => ToolOutcome {
4529                                        text: launch_success_json(
4530                                            &launch.session_id,
4531                                            &launch.command_id,
4532                                        )?,
4533                                        store_result: true,
4534                                        ok: true,
4535                                        end_session: false,
4536                                        freeform_write: None,
4537                                        managed_source_snapshot: None,
4538                                        exact_result: true,
4539                                    },
4540                                    Err(error)
4541                                        if matches!(
4542                                            error.kind,
4543                                            HistoryErrorKind::InvalidInput
4544                                                | HistoryErrorKind::Conflict
4545                                        ) =>
4546                                    {
4547                                        ToolOutcome {
4548                                            text: format!(
4549                                                "LaunchSession failed: {}",
4550                                                error.message
4551                                            ),
4552                                            store_result: true,
4553                                            ok: false,
4554                                            end_session: false,
4555                                            freeform_write: None,
4556                                            managed_source_snapshot: None,
4557                                            exact_result: false,
4558                                        }
4559                                    }
4560                                    Err(error) => {
4561                                        return Err(anyhow::anyhow!(
4562                                            "LaunchSession remains unresolved ({}): {}",
4563                                            error.kind.code(),
4564                                            error.message
4565                                        ));
4566                                    }
4567                                }
4568                            }
4569                            Err(error) => ToolOutcome {
4570                                text: format!("LaunchSession failed: {error}"),
4571                                store_result: true,
4572                                ok: false,
4573                                end_session: false,
4574                                freeform_write: None,
4575                                managed_source_snapshot: None,
4576                                exact_result: false,
4577                            },
4578                        }
4579                    } else {
4580                        match self.session.execute_tool(&call, operation_id).await {
4581                            Ok(outcome) => {
4582                                emitted_response = call.name == "EmitObject" && outcome.ok;
4583                                outcome
4584                            }
4585                            Err(error) => ToolOutcome {
4586                                text: format!("{} failed: {error}", call.name),
4587                                store_result: call.name != "LoadNodes",
4588                                ok: false,
4589                                end_session: false,
4590                                freeform_write: None,
4591                                managed_source_snapshot: None,
4592                                exact_result: false,
4593                            },
4594                        }
4595                    }
4596                }
4597                Err(error) => ToolOutcome {
4598                    text: error.to_string(),
4599                    store_result: true,
4600                    ok: false,
4601                    end_session: false,
4602                    freeform_write: None,
4603                    managed_source_snapshot: None,
4604                    exact_result: false,
4605                },
4606            };
4607            if let Some(snapshot) = outcome.managed_source_snapshot.take() {
4608                apply_snapshot(&mut self.session.journal, &now(), snapshot)?;
4609                outcome.store_result = false;
4610            }
4611            if !outcome.exact_result {
4612                append_slow_tool_duration(&mut outcome.text, tool_started_at.elapsed());
4613            }
4614            self.state.exact_tool_result = outcome.exact_result;
4615            let capture = if let Some(request) = outcome.freeform_write.take() {
4616                self.state.pending_freeform_write = Some(PendingFreeformWrite {
4617                    request,
4618                    call_box_id: created_call_box_id
4619                        .context("freeform write call box was not created")?,
4620                });
4621                Some(json!(true))
4622            } else {
4623                None
4624            };
4625            if outcome.store_result {
4626                outcome.text = ensure_tool_result_box(
4627                    &mut self.session.journal,
4628                    recorded_invocation.as_ref(),
4629                    &outcome.text,
4630                    outcome.ok,
4631                )?;
4632            }
4633            let external_event_id = self.session.pending_external_event_id.clone();
4634            let recovery = self
4635                .session
4636                .recover_context_overflow(external_event_id.as_deref(), &[])?;
4637            let context_warning_added =
4638                self.session.transcript[transcript_start..]
4639                    .iter()
4640                    .any(|entry| {
4641                        entry.get("contextOverflowWarning").and_then(Value::as_bool) == Some(true)
4642                    });
4643            let mut provider_text = outcome.text.clone();
4644            if !outcome.exact_result
4645                && context_warning_added
4646                && !provider_text.contains(CONTEXT_OVERFLOW_WARNING)
4647            {
4648                if !provider_text.is_empty() {
4649                    provider_text.push_str("\n\n");
4650                }
4651                provider_text.push_str(CONTEXT_OVERFLOW_WARNING);
4652            }
4653            self.session.record_tool_completion(
4654                recorded_invocation.as_ref(),
4655                json!({"ok":outcome.ok,"result":outcome.text}),
4656            )?;
4657            let stop = recovery == ContextRecovery::Irreducible
4658                || (matches!(self.session.mode, AgentMode::Ingress { .. })
4659                    && self.session.ingress_force_commit_requested())
4660                || (!matches!(self.session.mode, AgentMode::Ingress { .. })
4661                    && self.session.journal.state().source_terminated);
4662            Ok(kcode_agent_runtime::SessionToolOutcome {
4663                text: provider_text,
4664                ok: outcome.ok,
4665                capture,
4666                stop,
4667                finish_after_round: outcome.end_session,
4668                emitted_response,
4669            })
4670        })
4671    }
4672
4673    fn prepare_provider_resume<'a>(
4674        &'a mut self,
4675        mut outcome: kcode_agent_runtime::SessionToolOutcome,
4676    ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::ProviderResume> {
4677        Box::pin(async move {
4678            if completes_before_provider_resume(&outcome) {
4679                (self.checkpoint)(self.session.snapshot()?).await?;
4680                return Ok(kcode_agent_runtime::ProviderResume::Complete(None));
4681            }
4682
4683            let ingress_time = self
4684                .session
4685                .ingress_time_remaining()?
4686                .map(|remaining| (remaining, self.session.previous_ingress_attempt_timed_out));
4687            let synchronized_after = self
4688                .state
4689                .provider_synchronized_after
4690                .context("provider resume was prepared before its input was recorded")?;
4691            let prepared = self.session.journal.prepare_provider_resume(
4692                now(),
4693                synchronized_after,
4694                ingress_time,
4695            )?;
4696            match apply_prepared_provider_resume(
4697                &mut self.session.provider_affinity,
4698                &mut self.session.next_thread_reset_reason,
4699                prepared,
4700            ) {
4701                NativeProviderResumePreparation::Continue { marker_lines } => {
4702                    self.state.provider_synchronized_after = Some(
4703                        self.session
4704                            .journal
4705                            .state()
4706                            .events
4707                            .last()
4708                            .context("provider resume preparation left no journal event")?
4709                            .id,
4710                    );
4711                    if self.state.exact_tool_result {
4712                        self.state.exact_tool_result = false;
4713                    } else {
4714                        let mut footer_lines = marker_lines;
4715                        footer_lines.extend(self.session.runtime_budget().footer_lines());
4716                        outcome.text = provider_tool_result_with_context_footer(
4717                            &footer_lines.join("\n"),
4718                            &outcome.text,
4719                        );
4720                    }
4721                    (self.checkpoint)(self.session.snapshot()?).await?;
4722                    Ok(kcode_agent_runtime::ProviderResume::Continue(outcome))
4723                }
4724                NativeProviderResumePreparation::RestartFresh { reason } => {
4725                    self.state.exact_tool_result = false;
4726                    self.state.restart_fresh_reason = Some(reason);
4727                    (self.checkpoint)(self.session.snapshot()?).await?;
4728                    Ok(kcode_agent_runtime::ProviderResume::RestartFresh)
4729                }
4730            }
4731        })
4732    }
4733
4734    fn complete_capture<'a>(
4735        &'a mut self,
4736        _capture: Value,
4737        contents: String,
4738    ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::SessionControl> {
4739        Box::pin(async move {
4740            let pending = self
4741                .state
4742                .pending_freeform_write
4743                .take()
4744                .context("provider completed without a pending freeform write")?;
4745            let result_metadata = pending.request.clone();
4746            let outcome = self
4747                .session
4748                .complete_freeform_write(pending, contents)
4749                .await?;
4750            if outcome.store_result {
4751                self.session.journal.create_box(
4752                    now(),
4753                    "Kennedy tool result",
4754                    BoxOwner::Controller,
4755                    BoxContent::text(&outcome.text),
4756                )?;
4757            }
4758            self.session.journal.record(
4759                now(),
4760                EventKind::Note {
4761                    label: "write_file_freeform_result".into(),
4762                    value: result_metadata.result_record(outcome.ok, &outcome.text),
4763                },
4764            )?;
4765            let external_event_id = self.session.pending_external_event_id.clone();
4766            let recovery = self
4767                .session
4768                .recover_context_overflow(external_event_id.as_deref(), &[])?;
4769            let snapshot = self.session.snapshot()?;
4770            (self.checkpoint)(snapshot).await?;
4771            if recovery == ContextRecovery::Irreducible
4772                || (matches!(self.session.mode, AgentMode::Ingress { .. })
4773                    && self.session.ingress_force_commit_requested())
4774                || (!matches!(self.session.mode, AgentMode::Ingress { .. })
4775                    && self.session.journal.state().source_terminated)
4776                || self.state.deadline_after_response
4777            {
4778                return Ok(kcode_agent_runtime::SessionControl::Complete(None));
4779            }
4780            self.session.journal.create_box(
4781                now(),
4782                controller_box_name(&self.session.mode),
4783                BoxOwner::Controller,
4784                BoxContent::text(controller_message(
4785                    &self.session.mode,
4786                    &self.session.free_time,
4787                )),
4788            )?;
4789            Ok(kcode_agent_runtime::SessionControl::Continue)
4790        })
4791    }
4792
4793    fn complete_round<'a>(
4794        &'a mut self,
4795        completion: kcode_agent_runtime::RoundCompletion,
4796    ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::SessionControl> {
4797        Box::pin(async move {
4798            let answer = completion.answer.trim().to_owned();
4799            let mut completion_recovery = ContextRecovery::NotNeeded;
4800            if !answer.is_empty() {
4801                let mut content = BoxContent::text(answer.clone());
4802                if let Some(id) = &self.session.pending_external_event_id {
4803                    content.metadata["externalEventId"] = json!(id);
4804                }
4805                self.session.journal.create_box(
4806                    now(),
4807                    "Kennedy message",
4808                    BoxOwner::Kennedy,
4809                    content,
4810                )?;
4811                let mut transcript = json!({"role":"kennedy","content":answer});
4812                if let Some(id) = &self.session.pending_external_event_id {
4813                    transcript["externalEventId"] = json!(id);
4814                }
4815                self.session.transcript.push(transcript);
4816                self.session.synchronize_provider_known_events();
4817                let external_event_id = self.session.pending_external_event_id.clone();
4818                completion_recovery = self
4819                    .session
4820                    .recover_context_overflow(external_event_id.as_deref(), &[])?;
4821            }
4822            let snapshot = self.session.snapshot()?;
4823            (self.checkpoint)(snapshot).await?;
4824            if completion_recovery == ContextRecovery::Irreducible
4825                || (matches!(self.session.mode, AgentMode::Ingress { .. })
4826                    && self.session.ingress_force_commit_requested())
4827                || (!matches!(self.session.mode, AgentMode::Ingress { .. })
4828                    && self.session.journal.state().source_terminated)
4829            {
4830                return Ok(kcode_agent_runtime::SessionControl::Complete(None));
4831            }
4832            if completion.finish_requested || self.state.deadline_after_response {
4833                return Ok(kcode_agent_runtime::SessionControl::Complete(
4834                    (!answer.is_empty()).then_some(answer),
4835                ));
4836            }
4837            if matches!(self.session.mode, AgentMode::Conversation) && !answer.is_empty() {
4838                return Ok(kcode_agent_runtime::SessionControl::Complete(Some(answer)));
4839            }
4840            if matches!(self.session.mode, AgentMode::Conversation) && completion.emitted_response {
4841                return Ok(kcode_agent_runtime::SessionControl::Complete(None));
4842            }
4843            let solo_ingress_response =
4844                matches!(self.session.mode, AgentMode::Ingress { .. }) && !answer.is_empty();
4845            anyhow::ensure!(
4846                completion.used_tool || solo_ingress_response,
4847                "provider completed without a response or tool call"
4848            );
4849            self.session.journal.create_box(
4850                now(),
4851                controller_box_name(&self.session.mode),
4852                BoxOwner::Controller,
4853                BoxContent::text(controller_message(
4854                    &self.session.mode,
4855                    &self.session.free_time,
4856                )),
4857            )?;
4858            Ok(kcode_agent_runtime::SessionControl::Continue)
4859        })
4860    }
4861}
4862
4863impl kcode_agent_runtime::Host for KennedySubagentHost<'_> {
4864    fn render_tool_call(&mut self, call: &kcode_agent_runtime::ToolCall) -> anyhow::Result<String> {
4865        Ok(tool_invocation_content(&call.name, &call.arguments)?.text)
4866    }
4867
4868    fn execute_tool<'a>(
4869        &'a mut self,
4870        call: kcode_agent_runtime::ToolCall,
4871        operation_id: Uuid,
4872        budget: kcode_agent_runtime::ContextBudget,
4873    ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::ToolOutcome> {
4874        Box::pin(async move {
4875            let call = ToolCall {
4876                name: call.name,
4877                arguments: call.arguments,
4878            };
4879            if let Some(reason) = subagent_unavailable_reason(&call.name) {
4880                return Ok(kcode_agent_runtime::ToolOutcome::failure(reason));
4881            }
4882            if budget.estimated_tokens() > budget.max_input_tokens() {
4883                return Ok(kcode_agent_runtime::ToolOutcome::failure(
4884                    "The Ktool call was not run because its retained invocation would exceed the subagent context limit.",
4885                ));
4886            }
4887            if !subagent_managed_write_fits(&self.context, &call, &budget) {
4888                return Ok(kcode_agent_runtime::ToolOutcome::failure(
4889                    "The managed-source write was not run because its resulting current state would exceed the subagent context limit.",
4890                ));
4891            }
4892
4893            let tool_started_at = std::time::Instant::now();
4894
4895            if call.name == "LoadNodes" {
4896                let Some(DecodedTool::LoadNodes(identifiers)) =
4897                    decode(&call.name, &call.arguments)?
4898                else {
4899                    return Ok(kcode_agent_runtime::ToolOutcome::failure(
4900                        "LoadNodes did not match its tool contract.",
4901                    ));
4902                };
4903                load_durable_batch(
4904                    self.session.api.kmap(),
4905                    self.context.kweb_mut(),
4906                    &identifiers,
4907                )?;
4908                let (updates, creates) = self.session.plan.context_projection();
4909                let changes = self.context.reconcile_kweb(&updates, &creates)?;
4910                let displayed_state_keys = changes.displayed_state_keys();
4911                let mut text = if changes.is_empty() {
4912                    "LoadNodes completed. The subagent Kweb projection was already current.".into()
4913                } else {
4914                    changes.display_text()
4915                };
4916                append_slow_tool_duration(&mut text, tool_started_at.elapsed());
4917                return Ok(kcode_agent_runtime::ToolOutcome {
4918                    text,
4919                    ok: true,
4920                    state_updates: changes.updates,
4921                    displayed_state_keys,
4922                    capture: None,
4923                });
4924            }
4925
4926            if is_kweb_mutation(&call.name) {
4927                self.session.assert_tool_allowed(&call.name)?;
4928                let decoded = decode(&call.name, &call.arguments)?
4929                    .with_context(|| format!("{} did not match its tool contract", call.name))?;
4930                let prior_create_count = self.session.plan.create_count();
4931                let (mut text, referenced_pending) = execute_kweb_mutation(
4932                    &call.name,
4933                    decoded,
4934                    self.context.kweb(),
4935                    &mut self.session.plan,
4936                    &mut self.session.journal,
4937                )?;
4938                self.context.include_staged_nodes(
4939                    referenced_pending
4940                        .into_iter()
4941                        .chain(self.session.plan.pending_ids_from(prior_create_count)),
4942                );
4943                let (updates, creates) = self.session.plan.context_projection();
4944                let changes = self.context.reconcile_kweb(&updates, &creates)?;
4945                append_slow_tool_duration(&mut text, tool_started_at.elapsed());
4946                return Ok(kcode_agent_runtime::ToolOutcome {
4947                    text,
4948                    ok: true,
4949                    state_updates: changes.updates,
4950                    displayed_state_keys: Vec::new(),
4951                    capture: None,
4952                });
4953            }
4954
4955            if let Some(request) = decode_freeform_write(&call.name, &call.arguments)? {
4956                if !self.context.source_is_open(request.kind(), request.name()) {
4957                    return Ok(kcode_agent_runtime::ToolOutcome::failure(format!(
4958                        "{} {:?} is not open in this subagent context. Call {} first.",
4959                        request.kind().label(),
4960                        request.name(),
4961                        request.kind().open_tool()
4962                    )));
4963                }
4964                let acknowledgement = request.acknowledgement();
4965                let id = Uuid::new_v4().to_string();
4966                self.captures.insert(id.clone(), request);
4967                return Ok(kcode_agent_runtime::ToolOutcome {
4968                    text: acknowledgement,
4969                    ok: true,
4970                    state_updates: Vec::new(),
4971                    displayed_state_keys: Vec::new(),
4972                    capture: Some(Value::String(id)),
4973                });
4974            }
4975
4976            let mut outcome = match self.session.execute_tool(&call, operation_id).await {
4977                Ok(outcome) => outcome,
4978                Err(error) => {
4979                    let mut text = format!("{} failed: {error}", call.name);
4980                    append_slow_tool_duration(&mut text, tool_started_at.elapsed());
4981                    return Ok(kcode_agent_runtime::ToolOutcome::failure(text));
4982                }
4983            };
4984            let displays_managed_snapshot = outcome
4985                .managed_source_snapshot
4986                .as_ref()
4987                .is_some_and(|snapshot| result_displays_snapshot(&outcome.text, snapshot));
4988            append_slow_tool_duration(&mut outcome.text, tool_started_at.elapsed());
4989            let (state_updates, displayed_state_keys) =
4990                if let Some(snapshot) = outcome.managed_source_snapshot.take() {
4991                    let state = self.context.apply_source_snapshot(snapshot);
4992                    let displayed = displays_managed_snapshot.then_some(state.key);
4993                    (
4994                        state.update.into_iter().collect(),
4995                        displayed.into_iter().collect(),
4996                    )
4997                } else {
4998                    (Vec::new(), Vec::new())
4999                };
5000            let capture = outcome.freeform_write.take().map(|request| {
5001                let id = Uuid::new_v4().to_string();
5002                self.captures.insert(id.clone(), request);
5003                Value::String(id)
5004            });
5005            Ok(kcode_agent_runtime::ToolOutcome {
5006                text: outcome.text,
5007                ok: outcome.ok,
5008                state_updates,
5009                displayed_state_keys,
5010                capture,
5011            })
5012        })
5013    }
5014
5015    fn complete_capture<'a>(
5016        &'a mut self,
5017        capture: Value,
5018        contents: String,
5019        budget: kcode_agent_runtime::ContextBudget,
5020    ) -> kcode_agent_runtime::HostFuture<'a, kcode_agent_runtime::ToolOutcome> {
5021        Box::pin(async move {
5022            let id = capture
5023                .as_str()
5024                .context("subagent freeform capture token is invalid")?;
5025            let request = self
5026                .captures
5027                .remove(id)
5028                .context("subagent freeform capture token is unknown")?;
5029            self.session
5030                .complete_subagent_freeform_write(&mut self.context, request, contents, &budget)
5031                .await
5032        })
5033    }
5034
5035    fn record(&mut self, event: kcode_agent_runtime::AuditEvent) -> anyhow::Result<()> {
5036        kcode_intelligence_chatend::record_subagent_event(&mut self.session.journal, &now(), &event)
5037    }
5038}
5039
5040fn cost_summary(label: &str, estimated_cost_usd_nanos: u64, unpriced_calls: u64) -> String {
5041    render(RenderRequest::CostSummary {
5042        label,
5043        estimated_cost_usd_nanos,
5044        unpriced_calls,
5045    })
5046    .expect("cost-summary rendering is infallible")
5047}
5048
5049fn restore_kweb_context(journal: &HistorySession, context: &mut KwebContext) -> anyhow::Result<()> {
5050    let Some(tool) = journal.state().tools.get(KWEB_TOOL_INSTANCE) else {
5051        return Ok(());
5052    };
5053    let mut nodes = BTreeMap::new();
5054    for slot in &tool.slots {
5055        let state = journal
5056            .state()
5057            .box_state(slot.box_id)
5058            .context("Kweb slot references a missing box")?;
5059        if let Some(node) = state.canonical.content.metadata.get("storedNode") {
5060            let node = match serde_json::from_value::<KwebNode>(node.clone()) {
5061                Ok(node) => node,
5062                Err(_) => node_from_value(node).context("decoding a stored Kweb context node")?,
5063            };
5064            nodes.insert(node.id.clone(), node);
5065        }
5066    }
5067    let mut direct = journal
5068        .state()
5069        .current_ingress_attempt_events()
5070        .iter()
5071        .flat_map(|event| {
5072            let EventKind::ToolInvoked {
5073                tool_name,
5074                arguments,
5075                ..
5076            } = &event.kind
5077            else {
5078                return Vec::new();
5079            };
5080            match tool_name.as_str() {
5081                "LoadNodes" => arguments
5082                    .get("identifiers")
5083                    .and_then(Value::as_array)
5084                    .into_iter()
5085                    .flatten()
5086                    .filter_map(Value::as_str)
5087                    .map(str::to_owned)
5088                    .collect(),
5089                "LoadNode" => arguments
5090                    .get("identifier")
5091                    .and_then(Value::as_str)
5092                    .map(str::to_owned)
5093                    .into_iter()
5094                    .collect(),
5095                _ => Vec::new(),
5096            }
5097        })
5098        .collect::<Vec<_>>();
5099    if direct.is_empty() {
5100        direct = context.root_node_ids().to_vec();
5101    }
5102    context
5103        .restore(nodes.into_values(), direct)
5104        .map_err(anyhow::Error::new)
5105}
5106
5107fn session_kind(session_type: &str, mode: &AgentMode) -> SessionKind {
5108    if matches!(mode, AgentMode::Ingress { .. }) {
5109        return SessionKind::HistoryIngress;
5110    }
5111    match session_type {
5112        "conversation" => SessionKind::Conversation,
5113        "telegram" => SessionKind::Telegram,
5114        "telegram-group" => SessionKind::TelegramGroup,
5115        "free-time" => SessionKind::SelfTime,
5116        "wakeup" => SessionKind::Other("wakeup".into()),
5117        "audio" => SessionKind::AudioIngress,
5118        other => SessionKind::Other(other.into()),
5119    }
5120}
5121
5122fn tool_instance_for_invocation(name: &str, invocation_id: &str) -> String {
5123    if name == "LoadNodes" {
5124        return KWEB_TOOL_INSTANCE.into();
5125    }
5126    format!("{name}:{invocation_id}")
5127}
5128
5129fn canonical_id(value: &str) -> anyhow::Result<String> {
5130    value
5131        .parse::<NodeId>()
5132        .with_context(|| format!("{value:?} is not a canonical node ID"))?;
5133    Ok(value.into())
5134}
5135
5136fn image_extension(media_type: &str) -> &'static str {
5137    match media_type
5138        .split(';')
5139        .next()
5140        .unwrap_or(media_type)
5141        .trim()
5142        .to_ascii_lowercase()
5143        .as_str()
5144    {
5145        "image/jpeg" => "jpg",
5146        "image/webp" => "webp",
5147        _ => "png",
5148    }
5149}
5150
5151fn call_ktool_description(include_launch_session: bool) -> String {
5152    let mut description = render(RenderRequest::CallKtoolDescription)
5153        .expect("Ktool-description rendering is infallible");
5154    if include_launch_session {
5155        description.push_str(
5156            "\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.",
5157        );
5158    }
5159    description
5160}
5161
5162fn now() -> String {
5163    Utc::now().to_rfc3339()
5164}
5165
5166fn deadline(value: &Value) -> Option<DateTime<Utc>> {
5167    value
5168        .get("deadlineAt")
5169        .and_then(Value::as_str)
5170        .and_then(|value| DateTime::parse_from_rfc3339(value).ok())
5171        .map(|value| value.with_timezone(&Utc))
5172}
5173
5174fn remaining_until(deadline: DateTime<Utc>) -> Duration {
5175    (deadline - Utc::now()).to_std().unwrap_or(Duration::ZERO)
5176}
5177
5178fn controller_box_name(mode: &AgentMode) -> &'static str {
5179    match mode {
5180        AgentMode::Conversation => "Turn continuation",
5181        AgentMode::FreeTime => "Self-time continuation",
5182        AgentMode::Wakeup => "Wakeup continuation",
5183        AgentMode::Ingress { .. } => "History-ingress continuation",
5184    }
5185}
5186
5187fn controller_message(mode: &AgentMode, free_time: &Value) -> String {
5188    let mode = match mode {
5189        AgentMode::Conversation => "conversation",
5190        AgentMode::FreeTime => "free-time",
5191        AgentMode::Wakeup => "wakeup",
5192        AgentMode::Ingress { .. } => "ingress",
5193    };
5194    render(RenderRequest::ControllerMessage { mode, free_time })
5195        .expect("known controller modes render successfully")
5196}