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