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