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